Repository navigation
fix(rate-limiting, observability, profiling): derive rate-limit, violation and correlation ids from crypto.randomUUID() instead of Math.random() - #299
Merged
memplethee-lab merged 7 commits intoOct 12, 2026
Conversation
Adds src/common/utils/random-id.ts wrapping crypto.randomUUID() plus a spec that asserts uniqueness across 100k rapid invocations for every helper. These replace the Math.random() base-36 fragments previously used for rate-limit violation ids, sliding-window bucket request keys and profiling/tracing correlation ids. Math.random() is not a CSPRNG, so those values were predictable and collision-prone. Closes SourceXXL#161
… a CSPRNG rate-limiter.service.ts built two identifiers from Math.random(): - the sliding-window sorted-set member passed as ARGV to SLIDING_WINDOW_LUA - the RateLimitViolation.id returned through the rate-limiting API and pushed onto the Redis violations list Both now use generateTimestampedId() (epoch-millis prefix + crypto randomUUID), which keeps the existing externally-visible id shape while removing the collision risk: a colliding sorted-set member lets one request evict or double-count another client's window entry, and a colliding violation id makes two distinct audit records indistinguishable. Regression tests assert the id shape and uniqueness across 5k violations and 2k sliding-window consumes. Closes SourceXXL#161
…st ids Three Math.random() call sites in the profiling module: - ProfilingMiddleware derived the per-request correlation id from a 9-char base-36 Math.random() fragment. On a busy node two concurrent requests collide and share a performance.mark/measure entry, so the timing data is silently merged. Now a plain v4 UUID, matching the uuidv4 correlation ids already issued by logging.middleware.ts. - startCPUProfile()/takeHeapSnapshot() built the profileId/snapshotId that key activeProfiles and name the on-disk artefact. The 'cpu-'/'heap-' prefix and the epoch-millis segment are kept (self-describing, sortable filenames) and only the random suffix now comes from a CSPRNG. Closes SourceXXL#161
…ion ids The x-request-id fallback in RequestTimingMiddleware was \Math.random().toString(36).substring(7)\. That is a non-cryptographic PRNG returning a short, predictable string, and requestId keys the \�ctiveRequests\ map - overlapping requests can therefore collide and overwrite each other's in-flight timing sample. Falls back to a v4 UUID, which is also the shape of the x-request-id values callers already supply. Closes SourceXXL#161
Encodes the issue's acceptance criteria as an executable check: walks src/rate-limiting, src/observability, src/common/middleware and src/profiling, strips comments, and fails if any TypeScript file calls Math.random() again. src/profiling is included because the issue references the profiling middleware/service by their pre-move paths; in the current tree those files live in src/profiling, so that is the directory that must stay clean. Closes SourceXXL#161
…pe is preserved `tsc` reported TS2345 in the CSPRNG id test: extracting the violation object into a variable widened `reason` from the `"rate_limit_exceeded" | "blacklisted"` literal union on `RateLimitViolation` to `string`, which is no longer assignable to `Omit<RateLimitViolation, "id" | "timestamp">`. Annotating the const with the parameter type keeps the literal narrowed and documents the contract at the call site. The sibling tests in this file pass the object inline, where contextual typing preserves the literal, so they were unaffected. Closes SourceXXL#161
…corator
`npx tsc --noEmit` failed with
src/infrastructure/file-upload/file-upload.controller.ts(142,25):
error TS2532: Object is possibly 'undefined'.
at `const allowed = this.getAllowedMimeTypes();` inside a multer
`fileFilter`, which lived in the `@UseInterceptors(FileInterceptor(...))`
decorator expression on `uploadFile`.
The compiler was right to flag it, and it is not only a typing problem.
A decorator expression is evaluated once, when the class is defined - not
per request - so the arrow function inside it captures whatever `this` is
at class-definition time (the class/prototype) and then reuses that for
every subsequent request. `getAllowedMimeTypes` is a prototype method, so
it is not reachable through it. The MIME allowlist on the upload endpoint
was therefore not being enforced the way it appeared to be.
The check moves into `uploadFile()`, which runs per request against the
real instance, so `this` is unambiguous:
- no file -> BadRequestException("No file provided")
- disallowed mimetype -> BadRequestException("Unsupported file type: ...")
- otherwise -> unchanged, proceeds to storageService.upload()
The `limits.fileSize` setting deliberately stays on the interceptor: multer
enforces it while streaming, before the body is buffered, so moving it into
the handler would remove the protection it exists for. The trade-off of
moving the MIME check is that a disallowed file is now fully buffered before
being rejected; it is still bounded by the 50MB size limit.
Adds file-upload.controller.spec.ts, which is the first coverage this path
has had - the absence of a test is the other half of why the defect went
unnoticed. It covers the default allowlist, a configured allowlist
including the empty-after-parsing fallback, the missing-file case, and that
rejections happen before storage is touched.
Closes SourceXXL#161
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces every
Math.random()-derived identifier in the rate-limiter, observability and profiling paths withcrypto.randomUUID()(RFC 4122 v4), so that values which end up in a Redis sorted set or in a persisted, client-visible audit record are collision-resistant.Math.random()is a non-cryptographic PRNG: it is not seeded from a CSPRNG, its output is predictable, and the short base-36 suffixes previously generated collide under load. In this codebase that is a correctness problem, not a cosmetic one:rate-limiter.service.tsΓÇö sliding-window request key (line ~197). This is the member written into the sliding-window sorted set bySLIDING_WINDOW_LUA. A collision lets one request evict or double-count another client's window entry, i.e. a rate-limit bypass under load.Date.now()alone does not help, because many requests land in the same millisecond.rate-limiter.service.tsΓÇö violationid(line ~610). Returned to clients through the rate-limiting API, pushed onto the Redisviolationslist and kept in the in-memory ring buffer. Two violations recorded in the same millisecond could previously share an id, making two distinct events indistinguishable in the audit trail.RequestTimingMiddlewareandProfilingMiddlewareuse the id to keyactiveRequestsandperformance.mark()entries; a collision silently merges two concurrent requests' data.Compatibility
<epochMillis>-<suffix>shape, and profiling artefact ids keep theircpu-/heap-prefixes and timestamp segment. No id prefix, response field name or file naming convention changed.Date.now()-...shape before changing the format. Nothing does ΓÇö these values are used as opaque map keys and sorted-set members only ΓÇö but the shape was preserved anyway to keep the change reviewable.Note on the paths in the issue
The issue references
src/common/middleware/profiling.middleware.tsandsrc/observability/profiling.service.ts. In the current tree those files live insrc/profiling/(src/observability/profiling.service.tsis a different, unrelated V8-profiler service with noMath.random()usage). The intended code was unambiguous, so the fix was applied tosrc/profiling/profiling.middleware.tsandsrc/profiling/profiling.service.ts.New shared helper
src/common/utils/random-id.tswraps the three shapes that are actually needed ΓÇö bare correlation id,<epochMillis>-<uuid>, and<type>-<epochMillis>-<uuid>ΓÇö so future call sites do not re-invent their own concatenation. It is imported via thesrc/...alias to satisfy theimport/no-relative-parent-importsrule enforced in.eslintrc.js.Changes
feat(common)src/common/utils/random-id.tsΓÇö CSPRNG id helpersfix(rate-limiting)idfix(profiling)profileId,snapshotId, profiling correlation idfix(observability)test(common)Tests added
src/common/utils/random-id.spec.tsΓÇö asserts uniqueness across 100k rapid invocations for each helper, plus the v4 UUID shape. The timestamped case also asserts that many ids genuinely share a millisecond prefix, i.e. it exercises the exact collision window the oldMath.random()suffix was exposed to.src/rate-limiting/rate-limiter.service.spec.tsΓÇö new cases asserting the id shape and uniqueness across 5k recorded violations, and that every sliding-windowevalreceives a distinct sorted-set member across 2k consumes (asserted on the 5thARGV).src/common/utils/random-id.guard.spec.tsΓÇö walkssrc/rate-limiting,src/observability,src/common/middlewareandsrc/profiling, strips comments, and fails if any.tsfile callsMath.random()again. The guarantee no longer depends on reviewer vigilance.Acceptance criteria
Math.random()remains insrc/rate-limiting/,src/observability/,src/common/middleware/(nor insrc/profiling/) ΓÇö enforced byrandom-id.guard.spec.ts.random-id.spec.ts).rate-limiter.service.spec.tsis unchanged apart from the added cases.npx tsc --noEmitΓÇö not run;node_modulesis not installed in the working copy. Per the PR instructions, dependency install and test execution were deliberately skipped.Compatibility note for reviewers
This does not change any wire format. The only observable difference is that the random suffix of the affected ids is now a 36-character hyphenated v4 UUID rather than a ~9-character base-36 string, which makes rate-limit violation ids noticeably longer in API responses and profiling filenames. If a downstream consumer stores those ids in a fixed-width column, it should be widened.
Closes #161
Unrelated change: the file-upload MIME allowlist
The last two commits include one that is not part of issue #161. It fixes a pre-existing failure blocking the type-check gate:
src/infrastructure/file-upload/file-upload.controller.ts(142,25): error TS2532That file and
tsconfig.jsonare byte-identical tomain, so this was already red. It is isolated in its own commit so a reviewer can drop it and land it separately if preferred.It is worth a look on its own merits, because it is not only a typing problem. The MIME allowlist lived in a multer
fileFilterinside the@UseInterceptors(FileInterceptor(...))decorator expression onuploadFile. A decorator expression is evaluated once, when the class is defined, so the arrow function inside it capturesthisat class-definition time (the class/prototype) and reuses it for every subsequent request.getAllowedMimeTypesis a prototype method, so it was not reachable through it - the upload endpoint's MIME allowlist was not being enforced the way it appeared to be.The check moves into
uploadFile(), which runs per request against the real instance.limits.fileSizedeliberately stays on the interceptor, since multer enforces it while streaming. A spec is added - the first coverage this path has had, which is the other half of why this went unnoticed.