Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ DEMO_MODE_ENABLED=false
DEMO_TTL_MINUTES=60
# Max demo account creations per IP per hour
DEMO_RATE_LIMIT_PER_HOUR=2

# Days to keep captured emails (0 disables the daily retention sweep)
EMAIL_RETENTION_DAYS=7
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
Expand All @@ -17,6 +18,7 @@ import { SmtpModule } from './smtp/smtp.module';
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
PrismaModule,
AuthModule,
ProjectsModule,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/demo/demo-cleanup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class DemoCleanupService {
private config: DemoConfig,
) {}

@Cron(CronExpression.EVERY_5_MINUTES)
@Cron(CronExpression.EVERY_30_MINUTES)
async handleCleanup() {
if (!this.config.enabled) return;
const cutoff = new Date(Date.now() - this.config.ttlMinutes * 60_000);
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/demo/demo.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { DemoCleanupService } from './demo-cleanup.service';
Expand All @@ -14,7 +13,7 @@ import { BlockDemoGuard } from './guards/block-demo.guard';
import { DevInboxDemoSeeder } from './seeders/devinbox-demo.seeder';

@Module({
imports: [ScheduleModule.forRoot(), PrismaModule, AuthModule],
imports: [PrismaModule, AuthModule],
providers: [
DemoConfig,
DemoService,
Expand Down
56 changes: 56 additions & 0 deletions apps/api/src/emails/attachment-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
contentDispositionFilename,
safeContentType,
} from './attachment-headers';

describe('safeContentType', () => {
it('passes through a well-formed mime type', () => {
expect(safeContentType('image/png')).toBe('image/png');
expect(safeContentType('application/vnd.ms-excel')).toBe(
'application/vnd.ms-excel',
);
});

it('falls back to octet-stream for renderable or malformed types', () => {
expect(safeContentType('text/html')).toBe('application/octet-stream');
expect(safeContentType('image/svg+xml')).toBe('application/octet-stream');
expect(safeContentType('application/xhtml+xml')).toBe(
'application/octet-stream',
);
expect(safeContentType(undefined)).toBe('application/octet-stream');
expect(safeContentType('not a mime type')).toBe('application/octet-stream');
expect(safeContentType('text/html\r\nX-Injected: 1')).toBe(
'application/octet-stream',
);
});
});

describe('contentDispositionFilename', () => {
it('quotes an ordinary filename', () => {
expect(contentDispositionFilename('report.pdf')).toBe(
'attachment; filename="report.pdf"',
);
});

it('does not let a quote escape the filename parameter', () => {
const header = contentDispositionFilename('a".txt');

expect(header).toBe('attachment; filename="a.txt"');
expect(header.match(/"/g)).toHaveLength(2);
});

it('strips CR/LF so headers cannot be split', () => {
const header = contentDispositionFilename('a\r\nX-Injected: 1');

expect(header).not.toContain('\r');
expect(header).not.toContain('\n');
});

it('always emits the attachment disposition', () => {
for (const name of ['inline; x', '', undefined, '../../etc/passwd']) {
expect(contentDispositionFilename(name).startsWith('attachment;')).toBe(
true,
);
}
});
});
44 changes: 44 additions & 0 deletions apps/api/src/emails/attachment-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Attachment metadata comes from inbound mail and is fully attacker
// controlled, so it is sanitised before being reflected in response headers.

const MIME_PATTERN =
/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/;

// Types a browser may render in-origin rather than treat as an opaque download.
const RENDERABLE = new Set([
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'application/xml',
'text/xml',
]);

export function safeContentType(contentType: string | undefined): string {
const fallback = 'application/octet-stream';
if (!contentType) return fallback;

const value = contentType.trim().toLowerCase();
if (!MIME_PATTERN.test(value)) return fallback;
if (RENDERABLE.has(value)) return fallback;

return value;
}

export function contentDispositionFilename(
filename: string | undefined,
): string {
// Drop quotes, backslashes and control characters: the first two would end
// the quoted-string early, the last could split the header.
const QUOTE = 0x22;
const BACKSLASH = 0x5c;
const cleaned = Array.from(filename ?? '')
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return false;
return code !== QUOTE && code !== BACKSLASH;
})
.join('')
.trim();

return `attachment; filename="${cleaned || 'unnamed'}"`;
}
63 changes: 63 additions & 0 deletions apps/api/src/emails/attachment-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as path from 'path';
import { safeAttachmentPath } from './attachment-path';

describe('safeAttachmentPath', () => {
const uploadsDir = path.join('/app', 'uploads', 'attachments');
const now = 1787046668457;

it('keeps an ordinary filename inside the uploads directory', () => {
const result = safeAttachmentPath(uploadsDir, 'report.pdf', now);

expect(result).toBe(path.join(uploadsDir, `${now}-report.pdf`));
});

it.each([
['../../../../etc/cron.d/pwn', 'pwn'],
['../../../../../../root/.ssh/authorized_keys', 'authorized_keys'],
['../escape.txt', 'escape.txt'],
['/etc/passwd', 'passwd'],
['nested/dir/file.txt', 'file.txt'],
])('strips traversal from %j', (input, expected) => {
const result = safeAttachmentPath(uploadsDir, input, now);

expect(result).toBe(path.join(uploadsDir, `${now}-${expected}`));
expect(path.dirname(result as string)).toBe(uploadsDir);
});

it('falls back to a placeholder when there is no usable filename', () => {
expect(safeAttachmentPath(uploadsDir, undefined, now)).toBe(
path.join(uploadsDir, `${now}-unnamed`),
);
expect(safeAttachmentPath(uploadsDir, '', now)).toBe(
path.join(uploadsDir, `${now}-unnamed`),
);
expect(safeAttachmentPath(uploadsDir, '..', now)).toBe(
path.join(uploadsDir, `${now}-unnamed`),
);
expect(safeAttachmentPath(uploadsDir, '.', now)).toBe(
path.join(uploadsDir, `${now}-unnamed`),
);
});

it('rejects filenames containing a null byte', () => {
expect(safeAttachmentPath(uploadsDir, 'evil\u0000.png', now)).toBeNull();
});

it('always resolves within the uploads directory', () => {
const hostile = [
'../../../../etc/passwd',
'....//....//etc/passwd',
'..',
'../',
'a/../../../b',
];

for (const name of hostile) {
const result = safeAttachmentPath(uploadsDir, name, now);
if (result === null) continue;
expect(
path.resolve(result).startsWith(path.resolve(uploadsDir) + path.sep),
).toBe(true);
}
});
});
32 changes: 32 additions & 0 deletions apps/api/src/emails/attachment-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as path from 'path';

/**
* Build the on-disk path for an inbound attachment.
*
* Attachment filenames come from the MIME headers of mail that anyone can send
* to the public SMTP listener, so they are untrusted. Only the basename is
* kept, and the result is checked to be inside `uploadsDir` before it is used.
*
* Returns null when the name cannot be made safe, in which case the attachment
* should be skipped rather than written.
*/
export function safeAttachmentPath(
uploadsDir: string,
filename: string | undefined,
now: number = Date.now(),
): string | null {
// A null byte can truncate the path inside libc, so refuse it outright.
if (filename?.includes('\u0000')) return null;

const base = path.basename(filename ?? '');
const safeName =
base === '' || base === '.' || base === '..' ? 'unnamed' : base;

const filepath = path.join(uploadsDir, `${now}-${safeName}`);

// Defence in depth: the basename above should make this unreachable.
const root = path.resolve(uploadsDir) + path.sep;
if (!path.resolve(filepath).startsWith(root)) return null;

return filepath;
}
12 changes: 12 additions & 0 deletions apps/api/src/emails/email-retention.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class EmailRetentionConfig {
constructor(private config: ConfigService) {}

// Days to keep captured emails. 0 (or less) disables retention sweeps.
get retentionDays(): number {
return parseInt(this.config.get('EMAIL_RETENTION_DAYS') || '7', 10);
}
}
109 changes: 109 additions & 0 deletions apps/api/src/emails/email-retention.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Logger } from '@nestjs/common';
import * as fs from 'fs';
import { PrismaService } from '../prisma/prisma.service';
import { EmailRetentionConfig } from './email-retention.config';
import { EmailRetentionService } from './email-retention.service';

describe('EmailRetentionService', () => {
let service: EmailRetentionService;
let prisma: {
email: { findMany: jest.Mock; deleteMany: jest.Mock };
};
let config: { retentionDays: number };
let logSpy: jest.SpyInstance;
let warnSpy: jest.SpyInstance;
let unlinkSpy: jest.SpyInstance;

beforeEach(() => {
prisma = { email: { findMany: jest.fn(), deleteMany: jest.fn() } };
config = { retentionDays: 7 };
service = new EmailRetentionService(
prisma as unknown as PrismaService,
config as unknown as EmailRetentionConfig,
);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
unlinkSpy = jest
.spyOn(fs.promises, 'unlink')
.mockResolvedValue(undefined as never);
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

it('is a no-op when retention is disabled', async () => {
config.retentionDays = 0;

await service.handleRetention();

expect(prisma.email.findMany).not.toHaveBeenCalled();
expect(prisma.email.deleteMany).not.toHaveBeenCalled();
});

it('deletes emails older than the retention cutoff', async () => {
const fixedNow = new Date('2026-08-18T12:00:00.000Z');
jest.useFakeTimers().setSystemTime(fixedNow);
prisma.email.findMany.mockResolvedValue([
{ id: 'e1', attachments: [] },
{ id: 'e2', attachments: [] },
]);
prisma.email.deleteMany.mockResolvedValue({ count: 2 });

await service.handleRetention();

const expectedCutoff = new Date(fixedNow.getTime() - 7 * 86_400_000);
expect(prisma.email.findMany).toHaveBeenCalledWith({
where: { receivedAt: { lt: expectedCutoff } },
select: { id: true, attachments: { select: { storagePath: true } } },
});
expect(prisma.email.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['e1', 'e2'] } },
});
expect(logSpy).toHaveBeenCalledWith(
'Deleted 2 email(s) older than 7 day(s)',
);
});

it('unlinks attachment files before deleting the rows', async () => {
prisma.email.findMany.mockResolvedValue([
{ id: 'e1', attachments: [{ storagePath: '/data/a.pdf' }] },
]);
prisma.email.deleteMany.mockResolvedValue({ count: 1 });

await service.handleRetention();

expect(unlinkSpy).toHaveBeenCalledWith('/data/a.pdf');
});

it('still deletes rows when an attachment file is already gone', async () => {
unlinkSpy.mockRejectedValue(new Error('ENOENT'));
prisma.email.findMany.mockResolvedValue([
{ id: 'e1', attachments: [{ storagePath: '/data/missing.pdf' }] },
]);
prisma.email.deleteMany.mockResolvedValue({ count: 1 });

await service.handleRetention();

expect(warnSpy).toHaveBeenCalledWith(
'Failed to delete attachment file: /data/missing.pdf',
);
expect(prisma.email.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['e1'] } },
});
});

it('does nothing further when no emails have expired', async () => {
prisma.email.findMany.mockResolvedValue([]);

await service.handleRetention();

expect(prisma.email.deleteMany).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
});
});
Loading
Loading