Skip to content

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 into
SourceXXL:mainfrom
memplethee-lab:feature/crypto-random-ids
Oct 12, 2026
Merged

memplethee-lab merged 7 commits into
SourceXXL:mainfrom
memplethee-lab:feature/crypto-random-ids

Conversation

@memplethee-lab

@memplethee-lab memplethee-lab commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Replaces every Math.random()-derived identifier in the rate-limiter, observability and profiling paths with crypto.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 by SLIDING_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 ΓÇö violation id (line ~610). Returned to clients through the rate-limiting API, pushed onto the Redis violations list 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.
  • Profiling / request-timing correlation ids. RequestTimingMiddleware and ProfilingMiddleware use the id to key activeRequests and performance.mark() entries; a collision silently merges two concurrent requests' data.

Compatibility

  • The externally visible violation id keeps its existing <epochMillis>-<suffix> shape, and profiling artefact ids keep their cpu- / heap- prefixes and timestamp segment. No id prefix, response field name or file naming convention changed.
  • The issue asks to check whether anything parses the 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.ts and src/observability/profiling.service.ts. In the current tree those files live in src/profiling/ (src/observability/profiling.service.ts is a different, unrelated V8-profiler service with no Math.random() usage). The intended code was unambiguous, so the fix was applied to src/profiling/profiling.middleware.ts and src/profiling/profiling.service.ts.

New shared helper

src/common/utils/random-id.ts wraps 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 the src/... alias to satisfy the import/no-relative-parent-imports rule enforced in .eslintrc.js.

Changes

Commit Scope
feat(common) src/common/utils/random-id.ts ΓÇö CSPRNG id helpers
fix(rate-limiting) sliding-window request key + violation id
fix(profiling) profileId, snapshotId, profiling correlation id
fix(observability) request-timing correlation id
test(common) guard test encoding the acceptance criteria

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 old Math.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-window eval receives a distinct sorted-set member across 2k consumes (asserted on the 5th ARGV).
  • src/common/utils/random-id.guard.spec.ts ΓÇö walks src/rate-limiting, src/observability, src/common/middleware and src/profiling, strips comments, and fails if any .ts file calls Math.random() again. The guarantee no longer depends on reviewer vigilance.

Acceptance criteria

  • No Math.random() remains in src/rate-limiting/, src/observability/, src/common/middleware/ (nor in src/profiling/) ΓÇö enforced by random-id.guard.spec.ts.
  • Generated ids are unique across 100k rapid invocations (asserted in random-id.spec.ts).
  • The existing sliding-window suite in rate-limiter.service.spec.ts is unchanged apart from the added cases.
  • npx tsc --noEmit ΓÇö not run; node_modules is 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 TS2532

That file and tsconfig.json are byte-identical to main, 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 fileFilter inside the @UseInterceptors(FileInterceptor(...)) decorator expression on uploadFile. A decorator expression is evaluated once, when the class is defined, so the arrow function inside it captures this at class-definition time (the class/prototype) and reuses it for every subsequent request. getAllowedMimeTypes is 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.fileSize deliberately 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.

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
@memplethee-lab
memplethee-lab merged commit 5c0cbf7 into SourceXXL:main Oct 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use crypto.randomUUID() instead of Math.random() for rate-limiter IDs and bucket request keys

1 participant