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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

### Security

- **`redactPaths` can no longer write through the prototype chain or into your application's objects** (`src/request-middleware.ts`, `README.md`; tests in `tests/request-middleware.spec.ts`). Resolves the CodeQL `js/prototype-polluting-assignment` finding (CWE-1321) on the path walker. The walker read each path segment as `container[segment]`, which follows inherited properties and runs getters, and it checked only the final target before writing. The `__proto__` / `constructor` / `prototype` deny-list kept it off `Object.prototype` itself, but three real defects remained:
- A path through an object parked on `Object.prototype` (by a library, or in an already polluted process) reached that one shared object and overwrote its field for every object in the process.
- A path through a response-header value kept by reference, such as a class instance with its own `toJSON()` or a `Date` carrying an own object field (`res.setHeader` stores non-string values as they are), reached a plain object your application owned and overwrote its field in the running app. With `redactPaths: ["responseHeaders.x-meta.inner.token"]`, `inner.token` read `"[REDACTED]"` after the request was logged.
- A getter on the path ran (application code during logging), and one that threw escaped the path pass, so the whole request went unlogged.
- The walker now steps through and writes into only the containers the package owns (arrays and objects whose prototype is exactly `Object.prototype`), descends from the entry only into the fields it copies (`requestBody`, `requestHeaders`, `responseHeaders`, `context`; a one-segment path such as `url` still redacts that field), reads only own data properties through their descriptors (never an inherited property, never an accessor, and the descriptor's own `value` / `writable`, so a polluted `Object.prototype.value` cannot make an accessor look writable and get its setter called), refuses `__proto__`, `constructor` and `prototype` on every segment with literal comparisons, and never throws, whatever the path holds. Every path that redacted before still redacts the same copy; a path that runs into a value kept by reference now stops there. If such a header's `toJSON()` printed the field the old write overwrote, the log line hid it only by corrupting your object; mask that header as a whole with `maskHeaderKeys` instead.
- `includeRequestHeaders` / `includeResponseHeaders` allow-lists now read only headers the request or response actually has. A listed header it lacked picked up a same-named property inherited from a polluted `Object.prototype`, logged it as that header, and passed the shared object to the path pass.
- A non-string entry in the `redactPaths` array is ignored. The middleware keeps using your array, so a path added after it was created still applies, but the construction-time check cannot see such an entry, and a non-string one made every request go unlogged.
- No option or output format changes.

## [1.2.0] - 2026-09-25

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,7 @@ narrowly scoped `redactPaths` API.
- `api_key`, `apikey`, `key`
- `code` (covers OAuth callback codes)
- `secret`, `password`
- **Surgical path redaction (`redactPaths`)**: opt-in array of dot-notation paths into the resolved entry (e.g. `["body.user.password"]`). The leading `body` segment is rewritten to `requestBody` to match the public field name. Missing intermediate keys are a graceful no-op — we never create new sub-paths just to write `[REDACTED]`. Body-scoped paths (`body.*` / `requestBody.*`) are applied to the object graph BEFORE the `maxBodyLength` truncation decision, so a secret targeted only by `redactPaths` (with no corresponding `maskBodyKeys` entry) cannot survive inside the `_preview` field of an over-limit body's truncation envelope. The request body, the `enrich()` context, and any captured request/response headers are all redacted on internal owned copies — even when the body or context is a class instance that defines its own `toJSON()` (a Mongoose document, a DTO, `req.user`) — and each `redactPaths` write is additionally confined to a package-owned plain object or array, so your original `req.body` / `req.session` / `req.user` / `req.headers` objects (and the live response-header values behind `res.getHeaders()`) are never mutated by a `redactPaths` write. Path redaction is also best-effort and never throws: a path that targets an array's `length`, a frozen or getter-only property, or any other slot whose write would raise is skipped rather than allowed to drop the entire log entry.
- **Surgical path redaction (`redactPaths`)**: opt-in array of dot-notation paths into the resolved entry (e.g. `["body.user.password"]`). The leading `body` segment is rewritten to `requestBody` to match the public field name. Missing intermediate keys are a graceful no-op — we never create new sub-paths just to write `[REDACTED]`. Body-scoped paths (`body.*` / `requestBody.*`) are applied to the object graph BEFORE the `maxBodyLength` truncation decision, so a secret targeted only by `redactPaths` (with no corresponding `maskBodyKeys` entry) cannot survive inside the `_preview` field of an over-limit body's truncation envelope. The request body, the `enrich()` context, and any captured request/response headers are all redacted on internal owned copies — even when the body or context is a class instance that defines its own `toJSON()` (a Mongoose document, a DTO, `req.user`) — and a path only ever steps through and writes into those package-owned plain objects and arrays, following their own data properties (never an inherited property, and never a getter, which is not run). So your original `req.body` / `req.session` / `req.user` / `req.headers` objects (and the live response-header values behind `res.getHeaders()`) are never mutated by a `redactPaths` write, and no path can reach `Object.prototype`: `__proto__`, `constructor` and `prototype` segments are refused. A header value the entry keeps by reference (a `Date`, a class instance with its own `toJSON()`, a `Buffer`, a `Map`) ends the path, because everything behind it is your application's state; to hide such a header, list it in `maskHeaderKeys`. A multi-segment path starts at `body` / `requestBody`, `requestHeaders`, `responseHeaders` or `context`; a single segment such as `url` redacts that field. Paths you add to the array after creating the middleware apply too; a non-string entry is ignored. Path redaction is also best-effort and never throws: a path that targets an array's `length`, a frozen or getter-only property, or any other slot it cannot safely write is skipped rather than allowed to drop the entire log entry.
- **Body size guard (`maxBodyLength`)**: prevents log-flood attacks. Object/array bodies whose JSON form exceeds the limit return the envelope `{ _truncated: true, _originalLength, _preview }` (preserving JSON shape for downstream log shippers). String bodies are truncated to `maxBodyLength` Unicode code points (handles emoji surrogate pairs correctly) with a trailing `…`.
- **Log injection (`escapeMessageNewlines`)**: untrusted user input concatenated into a log message — for example `logger.info(req.body.username)` where the request body carries `username = "alice\n[ERROR] (admin)\nfake critical event"` — writes three lines into the log file by default. The second and third lines are byte-for-byte indistinguishable from real `[ERROR]` entries the application itself produced and can mislead a SOC analyst, log parser, or incident responder into trusting forged data. Set `escapeMessageNewlines: true` on the `createLogger()` options to rewrite embedded `\n` / `\r` in string messages as the visible literal sequences `\n` / `\r` BEFORE the printf renders the line. Defaults to `false` for back-compat with consumers that intentionally emit multi-line messages; the recommendation for new applications is to opt in.

Expand Down
Loading
Loading