fix(logging): keep the record when a JSON enrichment cannot serialize - #1477
fix(logging): keep the record when a JSON enrichment cannot serialize#1477groupthinking wants to merge 4 commits into
Conversation
Closes #1452. `_format_json` (added in #1439) claimed `default=str` meant "a record is never lost to a serialization error". It does not. `default` is consulted only for values json cannot natively encode, and it is called unguarded, so two inputs still raise out of json.dumps inside Handler.emit — where logging swallows the exception via handleError and drops the record: * a circular container, rejected structurally before `default` runs; * a value whose __str__ raises, propagating back out of `default`. Reproduced on main, three logger.info calls with the middle one poisoned: 2 of 3 records reached the sink, for both inputs. Wraps the dump in a fallback that keeps every JSON-native scalar — so `level`, which downstream routing and alerting key on, stays authoritative — drops the unserializable enrichment, and records what failed in a `serialization_error` field rather than letting it vanish. `except Exception` deliberately, not a narrow tuple: (TypeError, ValueError, RecursionError) looks more correct but the exploding-__str__ case walks straight through it. Adds `_describe_exception`, because formatting the caught exception is itself the same hazard — it can come from a call site's own __str__, so its class may be one whose __str__ raises too. Falls back to the type name, a plain attribute lookup. Reachable via extra={"request_id": ...}, which populates `correlation_id` and which middleware may set to a framework object. Not a vulnerability and not reachable from request content: an attacker-supplied header is a string, and strings were already escaped correctly. The CWE-117 field forgery fix from #1429/#1439 is untouched. Verification on this head: * tests/unit/test_logging_config_crlf.py — 25 passed, the 20 from main unchanged, so the line-oriented and forgery contracts still hold. * Non-vacuous, and precisely: removing only the fallback block fails exactly the 3 tests that cover it (3 failed, 22 passed). * Reproduction now emits 3 of 3 records for both inputs, level=INFO, with serialization_error naming the cause. * Benign scalar enrichments still take the normal path — the fallback does not fire, and correlation_id still lands in the record. * ruff clean on both changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146p7ZfxtbdEfJjtgETZTNf
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review Auto-review skipped again — same unsatisfiable label gate root-caused on #1439 and tracked in #1425 ( This is a robustness fix on the error path of a security-sensitive formatter, so the claims worth attacking are specific:
The thing I'd rather you catch than take on trust: Note for scope: Generated by Claude Code |
|
Three open PRs implement this same fix. Flagging once here rather than on each, since all three are drafts and only one can land.
All three modify exactly
No opinion here on which is strongest — I have not reviewed the three approaches against each other, only established that they overlap. Generated by Claude Code |
|
✏️ Learnings added
|
|
Three open PRs implement #1452. Recommending this one as the winner, with #1471 and #1472 closed against it.
The functional difference between the three is nil — #1471's fallback omits Independent verificationI reproduced the bug and the fix rather than taking the description on trust. Against Against this head:
On where this came fromThe defect is mine. #1439 introduced Still a draft, so I'm not merging it; this is the reconciliation record, not an approval. Generated by Claude Code |
…valid
Found by the runbook's red-team pass over this PR's own diff, probing the
claim that the fallback makes the payload always serializable.
A third input defeats `default=str`, and it is quieter than the other two:
`json.dumps` renders a non-finite float as the JavaScript literals `NaN`
/`Infinity`, which are not valid JSON. It does not raise, so the record is
emitted and looks fine — then a strict downstream parser rejects it. That
is the same loss the rest of this PR prevents, relocated to the consumer
where it is harder to see. `json.loads` accepts those literals by default,
which is why the existing tests could not catch it.
Reachable the same way as the other two, via extra={"request_id": ...}
-> correlation_id; a duration-derived metric is a plausible source of inf.
`allow_nan=False` on both dumps turns it into a raise, which routes to the
fallback. `_is_json_safe_scalar` replaces the inline isinstance filter so
the fallback drops non-finite floats too rather than re-emitting them --
otherwise the guard would just move the invalid literal one line down.
Also narrows the scope note: this is a "serialization never loses a
record" guarantee, not "no record is ever lost". Building the payload can
still raise upstream of the try -- record.getMessage() on mismatched
%-args is the reachable case -- and that is out of scope because it fails
the line-oriented path identically via super().format(). The previous
comment claimed the broader guarantee, which is the exact defect class
#1452 exists to close.
Verification on this head:
* tests/unit/test_logging_config_crlf.py -- 29 passed.
* Non-vacuous: removing only allow_nan=False fails exactly the 3
non-finite tests (3 failed, 26 passed). The finite-float test keeps
passing, so the guard is not just rejecting all floats.
* 10-probe red-team sweep: circular, exploding __str__, inf, nan, deep
nesting (RecursionError), exc_info whose __str__ raises, lone
surrogate, forgery payload + poisoned enrichment, benign scalar --
all emit one physical line of strictly-valid JSON with level
authoritative. Bad %-args still loses the record, as documented.
* ruff clean on both changed files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146p7ZfxtbdEfJjtgETZTNf
Red-team pass — one finding, fixed in
|
| Probe | Result |
|---|---|
circular container · exploding __str__ |
record survives, enrichment dropped, cause reported |
inf · -inf · nan |
now strict-valid; previously emitted Infinity/NaN |
20k-deep nesting (RecursionError) |
caught by except Exception, record survives |
exc_info whose __str__ raises |
survives — formatException handles it upstream |
lone surrogate \ud800 |
escaped, round-trips |
| #1429 forgery payload + poisoned enrichment | level stays INFO, no forged field — the fallback is a second serialization path and the security property holds on it too |
| benign scalar / finite float enrichment | normal path, no serialization_error |
bad %-args |
record lost — pre-existing, out of scope, now documented |
Claims 2 and 4 held as stated: the fallback dump cannot raise (a str subclass with an exploding __str__ is still emitted directly by the encoder, never through default), and except Exception is load-bearing — the exploding-__str__ case raises RuntimeError and walks straight through (TypeError, ValueError, RecursionError).
Non-vacuity
Removing only allow_nan=False fails exactly the 3 non-finite tests (3 failed, 26 passed). The finite-float test keeps passing, so the guard is not simply rejecting all floats. Suite is 29 passed; ruff clean.
On the review
CodeRabbit accepted the request (1f2280d0) and then hit "Review limit reached — next review available in 53 minutes". Not re-triggering; the push above will re-arm it. Worth stating plainly that as of this comment no third party has reviewed this diff — and .coderabbit.yaml path_filters excludes tests/**, so when it does run, the 5 new tests stay out of scope and CI's test job remains their only third-party execution.
Generated by Claude Code
Credit to #1471, a competing implementation of #1452 opened two minutes before this one, which caught a hole in this branch's filter. The fallback's json.dumps has no `default=` on purpose -- anything reaching `default` there would reinstate the raising-__str__ hole -- so the scalar filter is the only thing preventing a second, fatal raise. Written with `isinstance`, it does not hold: `isinstance` consults `value.__class__`, which an object can forge with a property returning `str`. Such a value passes the filter, reaches the default-less dump, raises TypeError, and costs the record the fallback exists to save. Reproduced against this branch's previous head: isinstance(v, str) : True _is_json_safe_scalar(v) : True <- passes fallback json.dumps : TypeError end-to-end : 2 of 3 records reached the sink `json` dispatches on the real runtime type, which cannot be forged, so `type(value) is` is what makes this filter agree with the encoder rather than merely assert agreement. Note the two findings are independent and both are needed: #1471's filter is exact about the type but still admits non-finite floats, which serialize without raising into the invalid JSON literals NaN/Infinity. This keeps the finite check on top of the exact-type match. Verification: * 30 passed. * Non-vacuous: restoring the isinstance form fails exactly the new forged-class test (1 failed, 29 passed). * ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146p7ZfxtbdEfJjtgETZTNf
Both salvaged from #1472, a competing implementation of #1452. They pass unmodified on this head, so they are pure coverage gain rather than a behaviour change. * An enrichment whose __str__ raises an exception that ITSELF raises when rendered. This is what makes _describe_exception's inner guard live code rather than defensive decoration -- the existing unit test calls the helper directly, which cannot show the path is reachable through the formatter. * A traceback must survive the fallback. `exception` is rendered to str before the guarded dump, so a poisoned enrichment firing the fallback must not also cost the traceback. With this, the branch carries the union of all four competing PRs: #1471's exact-type filter, #1472's two tests, and the non-finite float guard found by this run's red-team pass, which none of the others have. 32 passed. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146p7ZfxtbdEfJjtgETZTNf
|
Superseded — #1452 landed as #1491 while this was open, so the base fix here is now on Two findings in this PR are not on main and were the most valuable of the set:
Both carried into #1497. Recommend closing this in favour of that one. Generated by Claude Code |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
Canonical issue
Closes #1452
Outcome
A log record is no longer lost when an optional JSON enrichment cannot be serialized. It is emitted with
levelauthoritative and aserialization_errorfield naming what failed._format_jsonarrived in #1439 with this comment on its return:That claim is false.
defaultis consulted only for valuesjsoncannot natively encode, and it is called unguarded, so two inputs still raise out ofjson.dumpsinsideHandler.emit— whereloggingswallows the exception viahandleErrorand drops the record:json.dumpsrejects it structurally, beforedefaultis ever reached;__str__raises —defaultis consulted, and the exception propagates straight back out of it.Reproduced against
main(a79f866) before changing anything — threelogger.infocalls through a realStreamHandler, the middle one poisoned:Same reproduction on this head:
Reachability
Via
extra={"request_id": ...}, which populatescorrelation_id, and which middleware may set to a framework object rather than a string. Only the optional enrichments (performance_ms,correlation_id) can carry such a value — every other field in the payload is already a scalar.Not reachable from request content, and not a vulnerability: an attacker-supplied header is a string, and strings were already escaped correctly. This is a robustness gap plus a comment that claimed more than the code enforced.
Scope
logging_config.py— a fallback around thejson.dumpscall that keeps every JSON-native scalar, drops the unserializable enrichment, and reports the cause; a new_describe_exceptionhelper; and the comment rewritten to state the guarantee the code actually provides.tests/unit/test_logging_config_crlf.py— +5 tests in the existing CWE-117 file.sanitize_log_recordand thejson_output=Falsebranch are unchanged.JSON_LOGGINGdefault mismatch (production_config.py:72vslogging_config.py) — still open, still a separate behavioural decision.Risk
serialization_errorcarries the exception type and message into the record itself, which is strictly more visible than thehandleErrorstderr noise it replaces. The secondjson.dumpscannot raise, because every value it receives has been filtered to a JSON-native scalar.git revert. No migration, config, or schema change. The emitted field set is unchanged for every record that serialized successfully before.Verification
Head
90a013a. Measured, not inferred.Focused tests —
tests/unit/test_logging_config_crlf.py: 25 passed. The 20 tests frommainare unchanged and still pass, so both the line-oriented contract and the fix(security): CWE-117 JSON field forgery via unescaped"survives the #1270 log sanitizer #1429 forgery contract are intact.Non-vacuous, and precisely so. Removing only the fallback block — leaving the tests and the new helper in place — fails exactly the three tests that cover it, and nothing else:
The other two new tests correctly do not depend on the fallback: one asserts a benign scalar enrichment still takes the normal path, the other tests
_describe_exceptiondirectly.The fallback honours the same guarantees as the happy path. A record that hits it with the fix(security): CWE-117 JSON field forgery via unescaped
"survives the #1270 log sanitizer #1429 forgery payload in the message still renders as one physical line, still parses, still haslevel == "INFO", and still has no forged field.Benign records are unaffected.
extra={"request_id": "req-123"}still lands ascorrelation_idthrough the normal path, with noserialization_errorfield — so the fallback does not fire on serializable values.except Exception, deliberately, not a narrow tuple.(TypeError, ValueError, RecursionError)reads as more correct and is not: the exploding-__str__case raisesRuntimeErrorand walks straight through it. Noted at the call site so it does not get "tidied" later.Describing the caught exception is itself guarded. It can originate in a call site's own
__str__, so its class may be one whose__str__raises too — which would re-raise inside the handler and lose the record a second time._describe_exceptionfalls back to the type name, a plain attribute lookup. Covered by its own test.Blast radius checked, not assumed.
_format_jsonis called from exactly one place (StructuredFormatter.format), andtests/unit/test_logging_config_crlf.pyis the only test file in the repo that references the module.Lint —
ruffclean on both changed files.Required CI — will populate on this head.
Review threads resolved — none open yet.
Production evidence
Not applicable as a preview: this is backend logging with no
apps/web/**surface, which is what gate 4 ofMERGE_POLICY.mdscopes previews to.The runtime evidence that matters is the reproduction, run against the real formatter through a real
StreamHandlerin both directions — 2 of 3 records onmain, 3 of 3 on this head, for both inputs. Both transcripts are above.production_config.py:72defaultsJSON_LOGGINGto"true", so the JSON path is the live one.Agent handoff
default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 was filed precisely so this would not be re-derived a fourth time, and it is explicit that the fix lands after fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 (merged as715cbf52d) rather than alongside it. No other open PR touches_format_json.default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452: a circular container and an exploding__str__are both still emitted withlevelauthoritative; a regression test in the shape of the existing ones covers both and fails on the pre-fix implementation; and the_format_jsoncomment now states the guarantee the code actually provides, including whydefault=stralone is not it.Agent provenance
Agent-authored, under the PR remediation runbook. Found by three independent red-team passes on #1439, each of which derived it and declined to push — reasonably, since folding it in would have reset that PR's green CI. #1452 exists so the fourth pass reads it instead of re-deriving it; this PR is that fourth pass acting on it, now that #1439 has merged and
_format_jsonexists onmain.Generated by Claude Code