Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m12s
CI / Build container images (pull_request) Successful in 3m4s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m35s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
Every upload stores the SHA-256 of its bytes, computed from the in-memory buffer that is written — never by re-reading disk. Every download re-hashes the stored object BEFORE the first byte leaves (memory bounded by the max_file_bytes quota that gated the upload) and fails closed on mismatch with attachment_integrity_failure; the mismatch lands in the audit trail as file.integrity_failed with both hashes. Detection of payload manipulation is the one integrity duty par. 52 VSA leaves with the application — only it knows what the file should be. Pre-#199 rows are hashed by a bounded, idempotent backfill that rides the existing nightly orphan-file-sweep job (no new scheduler job, job fence untouched); unreadable files are logged and retried, never silently skipped, and null-hash rows are served unverified only until the backfill reaches them. Operator runbook note in security.md (restore from backup, re-download, audit entry carries both hashes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
148 lines
6.1 KiB
TypeScript
148 lines
6.1 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
import { INestApplication, InternalServerErrorException } from '@nestjs/common';
|
|
import { PrismaClient, User } from '@prisma/client';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
|
import { createTestApp } from '../testing/test-app';
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
import { FileStorageService } from './file-storage.service';
|
|
import { FilesService } from './files.service';
|
|
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const pngBuffer = (payload: string): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]);
|
|
const sha256 = (buffer: Buffer): string => createHash('sha256').update(buffer).digest('hex');
|
|
|
|
/**
|
|
* Attachment integrity (issue #199): uploads store the SHA-256 of the
|
|
* written bytes, downloads verify it and fail closed (audited) on mismatch,
|
|
* and the nightly backfill hashes pre-#199 rows idempotently, reporting
|
|
* unreadable files instead of skipping them.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
let files: FilesService;
|
|
let storage: FileStorageService;
|
|
let user: User;
|
|
let pondId: string;
|
|
const suffix = uniqueSuffix();
|
|
|
|
async function uploadPng(payload: string): Promise<{ id: string; bytes: Buffer }> {
|
|
const bytes = pngBuffer(payload);
|
|
const view = await files.upload(user, pondId, {
|
|
buffer: bytes,
|
|
size: bytes.length,
|
|
originalname: `${payload}.png`,
|
|
});
|
|
return { id: view.id, bytes };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
app = await createTestApp();
|
|
files = app.get(FilesService);
|
|
storage = app.get(FileStorageService);
|
|
|
|
const users = app.get(UsersService);
|
|
user = await users.createUser({
|
|
username: `ines-integrity-${suffix}`,
|
|
email: `ines-integrity-${suffix}@example.org`,
|
|
displayName: `Ines Integrity ${suffix}`,
|
|
password: 'jedes byte bleibt wie es war 1',
|
|
locale: 'en',
|
|
});
|
|
// Verification via the endpoint (not markEmailVerified) because only the
|
|
// endpoint creates the personal pond the uploads go into.
|
|
const token = await app.get(AuthTokensService).issue(user.id, 'EMAIL_VERIFICATION', 600);
|
|
await request(app.getHttpServer())
|
|
.post('/api/v1/auth/verify-email')
|
|
.send({ token })
|
|
.expect(204);
|
|
const pond = await prisma.pond.findFirstOrThrow({ where: { ownerId: user.id } });
|
|
pondId = pond.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.auditEntry.deleteMany({
|
|
where: { action: 'file.integrity_failed', details: { path: ['pondId'], equals: pondId } },
|
|
});
|
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
|
const where = { pond: { owner: { username: { contains: suffix } } } };
|
|
await prisma.roleGrant.deleteMany({ where });
|
|
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('stores the hash of the written bytes at upload', async () => {
|
|
const { id, bytes } = await uploadPng('honest-upload');
|
|
const row = await prisma.attachment.findUniqueOrThrow({ where: { id } });
|
|
expect(row.sha256).toBe(sha256(bytes));
|
|
});
|
|
|
|
it('serves an intact file and fails closed, audited, on a tampered one', async () => {
|
|
const { id, bytes } = await uploadPng('will-be-tampered');
|
|
|
|
// Intact: the download succeeds and streams the exact bytes.
|
|
const intact = await files.download(null, id);
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of intact.stream) chunks.push(chunk as Buffer);
|
|
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);
|
|
|
|
// Tampered on disk (row untouched): fail closed with the dedicated code.
|
|
await storage.save(pondId, id, pngBuffer('evil-replacement'));
|
|
const failure = await files.download(null, id).catch((error: unknown) => error);
|
|
expect(failure).toBeInstanceOf(InternalServerErrorException);
|
|
expect((failure as InternalServerErrorException).getResponse()).toMatchObject({
|
|
code: 'attachment_integrity_failure',
|
|
});
|
|
|
|
// The mismatch is on the audit trail with both hashes.
|
|
const audit = await prisma.auditEntry.findFirst({
|
|
where: { action: 'file.integrity_failed', targetId: id },
|
|
});
|
|
expect(audit).not.toBeNull();
|
|
expect(audit!.details).toMatchObject({
|
|
expected: sha256(bytes),
|
|
actual: sha256(pngBuffer('evil-replacement')),
|
|
});
|
|
});
|
|
|
|
it('backfills missing hashes idempotently and reports unreadable files', async () => {
|
|
const readable = await uploadPng('backfill-me');
|
|
const unreadable = await uploadPng('bytes-will-vanish');
|
|
await prisma.attachment.updateMany({
|
|
where: { id: { in: [readable.id, unreadable.id] } },
|
|
data: { sha256: null },
|
|
});
|
|
await storage.delete(pondId, unreadable.id);
|
|
|
|
// A null-hash row is served unverified (pre-#199 status quo).
|
|
const unverified = await files.download(null, readable.id);
|
|
expect(unverified.attachment.sha256).toBeNull();
|
|
|
|
const first = await files.backfillHashes();
|
|
expect(first.hashed).toBeGreaterThanOrEqual(1);
|
|
expect(first.unreadable).toBeGreaterThanOrEqual(1);
|
|
const rehashed = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
|
expect(rehashed.sha256).toBe(sha256(readable.bytes));
|
|
|
|
// The unreadable row keeps its null hash — reported, retried next run,
|
|
// never silently marked done.
|
|
const vanished = await prisma.attachment.findUniqueOrThrow({ where: { id: unreadable.id } });
|
|
expect(vanished.sha256).toBeNull();
|
|
|
|
// Idempotent: a second run finds nothing new to hash here.
|
|
const second = await files.backfillHashes();
|
|
const third = await prisma.attachment.findUniqueOrThrow({ where: { id: readable.id } });
|
|
expect(third.sha256).toBe(sha256(readable.bytes));
|
|
expect(second.unreadable).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|