Skip to content
Merged
63 changes: 63 additions & 0 deletions src/common/utils/random-id.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as fs from "fs";
import * as path from "path";

/**
* Regression guard for the `Math.random()` -> `crypto.randomUUID()` migration.
*
* The identifiers that were generated with `Math.random()` are not just ugly:
* they are the members of the rate-limiter sliding-window sorted set and the
* ids of persisted, externally visible rate-limit violation records. A
* non-cryptographic PRNG makes those values predictable and collision-prone,
* which is a correctness problem rather than a style one.
*
* This test fails the moment somebody reintroduces `Math.random()` in the
* directories covered by the issue, so the guarantee does not depend on
* reviewer vigilance.
*/

const SRC_ROOT = path.resolve(__dirname, "..", "..");

/** Directories in scope, per the issue's acceptance criteria. */
const GUARDED_DIRECTORIES = [
path.join(SRC_ROOT, "rate-limiting"),
path.join(SRC_ROOT, "observability"),
path.join(SRC_ROOT, "common", "middleware"),
// The issue lists the profiling middleware/service by their pre-move paths
// (src/common/middleware/profiling.middleware.ts and
// src/observability/profiling.service.ts); in the current tree they live in
// src/profiling, so that is the directory that has to stay clean.
path.join(SRC_ROOT, "profiling"),
];

/**
* Strip comments so the explanatory comments that *mention* `Math.random()` do
* not trip the guard. Only executable code is interesting here.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}

function collectTypeScriptFiles(dir: string): string[] {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) return collectTypeScriptFiles(entryPath);
return entry.isFile() && entry.name.endsWith(".ts") ? [entryPath] : [];
});
}

describe("Math.random() is not used to build identifiers", () => {
it.each(GUARDED_DIRECTORIES)(
"has no Math.random() call sites in %s",
(dir) => {
const offenders = collectTypeScriptFiles(dir)
.filter((file) => /Math\s*\.\s*random\s*\(/.test(stripComments(fs.readFileSync(file, "utf8"))))
.map((file) => path.relative(SRC_ROOT, file));

expect(offenders).toEqual([]);
},
);
});
84 changes: 84 additions & 0 deletions src/common/utils/random-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
RANDOM_ID_ENTROPY_BITS,
UUID_V4_PATTERN,
generatePrefixedTimestampedId,
generateRandomId,
generateTimestampedId,
} from "./random-id";

describe("random-id", () => {
describe("generateRandomId", () => {
it("returns an RFC 4122 v4 UUID", () => {
expect(generateRandomId()).toMatch(UUID_V4_PATTERN);
});

it("stays unique across 100k rapid invocations", () => {
const COUNT = 100_000;
const seen = new Set<string>();

for (let i = 0; i < COUNT; i++) {
seen.add(generateRandomId());
}

expect(seen.size).toBe(COUNT);
});
});

describe("generateTimestampedId", () => {
it("keeps the <epochMillis>-<uuid> shape used by the rate-limiting API", () => {
const [timestamp, uuid] = generateTimestampedId().split("-", 2);

expect(Number(timestamp)).toBeGreaterThan(0);
expect(uuid).toMatch(UUID_V4_PATTERN);
});

it("stays unique across 100k rapid invocations within a single millisecond", () => {
// The whole point of the CSPRNG suffix: the timestamp prefix alone is
// *not* unique, because every call in a tight loop lands in the same
// millisecond. Two violations recorded in that millisecond used to be
// able to collide.
const COUNT = 100_000;
const seen = new Set<string>();
const prefixes = new Set<string>();

for (let i = 0; i < COUNT; i++) {
const id = generateTimestampedId();
prefixes.add(id.slice(0, id.indexOf("-")));
seen.add(id);
}

expect(seen.size).toBe(COUNT);
// Sanity check that the collisions we are guarding against are real:
// the generator genuinely ran inside a small number of milliseconds.
expect(prefixes.size).toBeLessThan(COUNT);
});
});

describe("generatePrefixedTimestampedId", () => {
it("prefixes the id with the supplied type segment", () => {
expect(generatePrefixedTimestampedId("cpu")).toMatch(
/^cpu-\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(generatePrefixedTimestampedId("heap")).toMatch(
/^heap-\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});

it("stays unique across 100k rapid invocations", () => {
const COUNT = 100_000;
const seen = new Set<string>();

for (let i = 0; i < COUNT; i++) {
seen.add(generatePrefixedTimestampedId("cpu"));
}

expect(seen.size).toBe(COUNT);
});
});

it("derives ids from a CSPRNG, not Math.random", () => {
// 122 random bits per v4 UUID. A base-36 `Math.random().toString(36)
// .slice(2, 9)` suffix carried well under 32 bits and collided under load.
expect(RANDOM_ID_ENTROPY_BITS).toBeGreaterThanOrEqual(122);
});
});
68 changes: 68 additions & 0 deletions src/common/utils/random-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { randomUUID } from "crypto";

/**
* Cryptographically secure random identifiers.
*
* These replace ad-hoc `Math.random()` identifiers that were previously used
* for rate-limit violation records, sliding-window bucket members and
* profiling/tracing correlation ids.
*
* `Math.random()` is a non-cryptographic PRNG: it is not seeded from a CSPRNG,
* its output is predictable, and collisions are realistic for the short
* base-36 suffixes that were being generated. That matters because the values
* produced here are:
*
* - written as *members* of the rate-limiter sliding-window sorted set, where a
* collision means one request evicts or double-counts another client's window
* entry (a rate-limit bypass under load), and
* - returned to clients as rate-limit violation `id` values and persisted into
* the audit trail, where a collision makes two distinct violations
* indistinguishable.
*
* `crypto.randomUUID()` (RFC 4122 v4) draws 122 bits from the platform CSPRNG,
* so collisions are not a practical concern.
*/

/** Number of random bits in a v4 UUID, before the version/variant bits. */
export const RANDOM_ID_ENTROPY_BITS = 122;

/**
* Canonical RFC 4122 v4 UUID string (36 chars, hyphenated).
*
* Use for correlation/tracing ids where the value is opaque and only needs to
* be globally unique.
*/
export function generateRandomId(): string {
return randomUUID();
}

/**
* `<epochMillis>-<uuid>` request/record identifier.
*
* The leading epoch-millisecond prefix is retained deliberately: several of
* these ids are externally visible on the rate-limiting API and are used to
* build on-disk profile filenames, where the timestamp keeps the entries
* lexicographically sortable. The suffix is now a CSPRNG UUID instead of a
* `Math.random()` base-36 fragment, which is what actually guarantees
* uniqueness.
*/
export function generateTimestampedId(): string {
return `${Date.now()}-${randomUUID()}`;
}

/**
* `<type>-<epochMillis>-<uuid>` identifier.
*
* Used for profiling artefacts (CPU profiles, heap snapshots) where the `type`
* segment makes the on-disk filename self-describing.
*/
export function generatePrefixedTimestampedId(prefix: string): string {
return `${prefix}-${Date.now()}-${randomUUID()}`;
}

/**
* RFC 4122 v4 UUID shape, used to assert in tests that generated ids keep the
* collision-resistant shape after the `Math.random()` removal.
*/
export const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
104 changes: 104 additions & 0 deletions src/infrastructure/file-upload/file-upload.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Test, TestingModule } from "@nestjs/testing";
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { FileUploadController } from "./file-upload.controller";
import { FileStorageService } from "./services/file-storage.service";
import { FileCleanupService } from "./services/file-cleanup.service";
import { FileScanService } from "./services/file-scan.service";
import { FileMetadataService } from "./services/file-metadata.service";

/**
* The upload MIME allowlist had no coverage at all, which is part of why the
* `fileFilter` problem went unnoticed: the check lived in a decorator
* expression, so it was neither reached by a test nor reliably invoked at
* runtime. It is now enforced in `uploadFile()`, which is what these exercise.
*/
describe("FileUploadController upload allowlist", () => {
let controller: FileUploadController;
let storageService: { upload: jest.Mock };
let configService: { get: jest.Mock };

const uploadedFile = (mimetype: string) =>
({
originalname: "upload",
mimetype,
size: 10,
buffer: Buffer.from("data"),
}) as unknown as Express.Multer.File;

const user = { id: "user-1" } as never;
const dto = {} as never;

beforeEach(async () => {
jest.clearAllMocks();

storageService = { upload: jest.fn().mockResolvedValue({ file: {}, thumbnails: [] }) };
configService = { get: jest.fn().mockReturnValue(undefined) };

const module: TestingModule = await Test.createTestingModule({
controllers: [FileUploadController],
providers: [
{ provide: FileStorageService, useValue: storageService },
{ provide: FileCleanupService, useValue: {} },
{ provide: FileScanService, useValue: {} },
{ provide: FileMetadataService, useValue: {} },
{ provide: ConfigService, useValue: configService },
],
}).compile();

controller = module.get<FileUploadController>(FileUploadController);
});

describe("default allowlist (FILE_ALLOWED_MIME_TYPES unset)", () => {
it("accepts an allowed mime type", async () => {
await expect(
controller.uploadFile(uploadedFile("image/png"), dto, user),
).resolves.toBeDefined();
expect(storageService.upload).toHaveBeenCalled();
});

it("rejects a mime type that is not on the allowlist", async () => {
await expect(
controller.uploadFile(uploadedFile("application/x-msdownload"), dto, user),
).rejects.toThrow(BadRequestException);
expect(storageService.upload).not.toHaveBeenCalled();
});

it("rejects a request with no file", async () => {
await expect(
controller.uploadFile(undefined as never, dto, user),
).rejects.toThrow("No file provided");
expect(storageService.upload).not.toHaveBeenCalled();
});
});

describe("configured allowlist (FILE_ALLOWED_MIME_TYPES set)", () => {
it("honours the configured list instead of the default", async () => {
configService.get.mockReturnValue("text/plain, application/json");

await expect(
controller.uploadFile(uploadedFile("text/plain"), dto, user),
).resolves.toBeDefined();

// On the default list, but not on the configured one.
await expect(
controller.uploadFile(uploadedFile("image/png"), dto, user),
).rejects.toThrow(BadRequestException);
});

it("falls back to the default list when the configured value is only separators", async () => {
configService.get.mockReturnValue(" , ,");

await expect(
controller.uploadFile(uploadedFile("image/png"), dto, user),
).resolves.toBeDefined();
});
});

it("rejects before touching storage when the user is unknown", async () => {
await expect(
controller.uploadFile(uploadedFile("image/png"), dto, undefined as never),
).rejects.toThrow("User identity not found");
expect(storageService.upload).not.toHaveBeenCalled();
});
});
41 changes: 23 additions & 18 deletions src/infrastructure/file-upload/file-upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,24 +133,19 @@ export class FileUploadController {
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
},
fileFilter: (req, file, cb) => {
const f = file;
if (!f) {
cb(new BadRequestException("No file provided"), false);
return;
}
const allowed = this.getAllowedMimeTypes();
if (allowed.has(f.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(
`Unsupported file type: ${f.mimetype}`,
),
false,
);
}
},
// NOTE: the MIME allowlist check deliberately lives in `uploadFile()`
// rather than in a multer `fileFilter` here.
//
// Decorator expressions are evaluated once, when the class is defined -
// not per request - so an arrow function inside one captures whatever
// `this` is at class-definition time (the class/prototype), and then
// reuses it for every subsequent request. Calling a prototype method
// through it is not the instance method, which is both why the type
// checker flags this and why the check was never reliable. `uploadFile`
// runs per request against the real instance, so `this` is correct.
//
// The size limit stays on the interceptor: multer enforces it while
// streaming, before the body is buffered, so it must not be moved.
}),
)
@ApiOperation({ summary: "Upload a file" })
Expand Down Expand Up @@ -179,6 +174,16 @@ export class FileUploadController {
if (!user?.id) {
throw new BadRequestException("User identity not found in request");
}

// MIME allowlist. See the note on the interceptor above for why this is
// here rather than in a multer `fileFilter`.
if (!file) {
throw new BadRequestException("No file provided");
}
if (!this.getAllowedMimeTypes().has(file.mimetype)) {
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
}

const userId = user.id;
const result = await this.storageService.upload(file, userId, dto);

Expand Down
Loading
Loading