fix(logging): keep the record when JSON serialization fails - #1493
fix(logging): keep the record when JSON serialization fails#1493groupthinking wants to merge 2 commits into
Conversation
`_format_json` 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
defeat it:
1. a circular container is rejected structurally, before `default` is
ever reached (`ValueError: Circular reference detected`);
2. a value whose `__str__` raises propagates straight out of `default`.
Either way `logging` swallows the raise via `Handler.handleError` and drops
the record entirely — the exact outcome the comment said was prevented.
Reproduced against main through a real handler: healthy → poisoned → healthy
emits 2 of 3 records for both inputs.
Reachable through the `correlation_id` / `performance_ms` enrichment loop
that #1439 added; the pre-#1439 template referenced neither field, so this
is a new failure mode rather than a pre-existing one. Not attacker-reachable
— every live call site passes a scalar, and an attacker-supplied header is a
string — so severity is low and the CWE-117 field-forgery fix is unaffected.
Wrap the dump and re-serialize with only the natively encodable fields, so a
bad enrichment costs its own value instead of the whole record, and record
why on the degraded record via `serialization_error`.
`except Exception` is deliberate, not a narrow tuple: the exploding-`__str__`
case walks straight through `(TypeError, ValueError, RecursionError)`.
The comment now states the guarantee the code actually provides.
Tests: +3 in the existing CWE-117 file. All three fail on the pre-fix
implementation and pass after it (3 failed, 20 passed → 23 passed), and one
of them pins that the degraded path still escapes attacker content, so the
fallback cannot become a hole in #1429.
Closes #1452
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmZfJAVpuZqEzE5jc9Bmtw
|
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@coderabbitai review Auto-review skipped again ( This is a follow-up hardening a path that #1439 just landed, so the claims worth attacking are narrow and specific:
One thing I'd rather you catch than take on trust: I assert this is not attacker-reachable because every live call site passes a scalar into Generated by Claude Code |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Changed files ==='
git diff --name-status HEAD^ HEAD
printf '%s\n' '=== Diff ==='
git diff --no-ext-diff --unified=80 HEAD^ HEAD -- \
logging_config.py tests/unit/test_logging_config_crlf.py
printf '%s\n' '=== Candidate files ==='
fd -a '^(logging_config\.py|test_logging_config_crlf\.py|metrics\.py)$' .
printf '%s\n' '=== logging_config.py outline ==='
ast-grep outline logging_config.py --items all --view expanded || true
printf '%s\n' '=== Relevant formatter source ==='
rg -n -C 12 '(_format_json|class StructuredFormatter|json\.dumps|correlation_id|performance_ms|formatException|formatStack)' \
logging_config.py
printf '%s\n' '=== Relevant tests ==='
rg -n -C 10 '(circular|exploding|serialization_fallback|StructuredFormatter|correlation_id|performance_ms)' \
tests/unit/test_logging_config_crlf.py
printf '%s\n' '=== Enrichment declaration and call sites ==='
rg -n -C 5 '(correlation_id|performance_ms|extra\s*=\s*\{)' \
-g '*.py' \
-g '!tests/unit/test_logging_config_crlf.py' \
.
printf '%s\n' '=== Formatter construction paths ==='
rg -n -C 5 '(StructuredFormatter\s*\(|setup_logging\s*\(|dictConfig\s*\()' -g '*.py' .Length of output: 9803 🏁 Script executed: #!/bin/bash
set -euo pipefail
config='src/youtube_extension/backend/config/logging_config.py'
metrics='src/youtube_extension/backend/middleware/metrics.py'
tests='tests/unit/test_logging_config_crlf.py'
printf '%s\n' '=== Implementation diff ==='
git diff --no-ext-diff --unified=100 HEAD^ HEAD -- "$config"
printf '%s\n' '=== Formatter outline ==='
ast-grep outline "$config" --items all --view expanded || true
printf '%s\n' '=== Formatter source ==='
rg -n -C 20 '(_format_json|class StructuredFormatter|json\.dumps|correlation_id|performance_ms|formatException|formatStack|formatTime)' "$config"
printf '%s\n' '=== Metrics source ==='
ast-grep outline "$metrics" --items all --view expanded || true
rg -n -C 12 '(correlation_id|performance_ms|extra\s*=\s*\{|request_id)' "$metrics"
printf '%s\n' '=== All enrichment and request-id paths ==='
rg -n -C 6 '(correlation_id|performance_ms|request_id|extra\s*=\s*\{)' \
-g '*.py' \
-g '!tests/unit/test_logging_config_crlf.py' \
src tests
printf '%s\n' '=== Logging setup and formatter construction ==='
rg -n -C 8 '(StructuredFormatter\s*\(|setup_logging\s*\(|dictConfig\s*\()' -g '*.py' src tests
printf '%s\n' '=== Full focused test file outline ==='
ast-grep outline "$tests" --items all --view expanded || trueLength of output: 94309 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Direct logging calls that set request_id in extra ==='
rg -n -P -U -C 5 \
'(?s)\blogger\.(?:debug|info|warning|error|exception|critical|log)\s*\(.*?\bextra\s*=\s*\{[^}]*["'\'']request_id["'\'']' \
-g '*.py' src/youtube_extension tests/unit 2>/dev/null || true
printf '%s\n' '=== Direct logging calls that set correlation_id in extra ==='
rg -n -P -U -C 5 \
'(?s)\blogger\.(?:debug|info|warning|error|exception|critical|log)\s*\(.*?\bextra\s*=\s*\{[^}]*["'\'']correlation_id["'\'']' \
-g '*.py' src/youtube_extension tests/unit 2>/dev/null || true
printf '%s\n' '=== All stdlib LogRecord enrichment assignment paths ==='
rg -n -C 5 \
'(LoggerAdapter|LogRecord|extra\s*=|request_id\s*=|correlation_id\s*=|duration\s*=)' \
-g '*.py' src/youtube_extension/backend tests/unit/test_logging_config_crlf.py \
| head -n 900
printf '%s\n' '=== Test helper and imports ==='
rg -n -C 20 '^(def _make_json_logger|def _emit_three|class _ExplodingStr|def test_)' \
tests/unit/test_logging_config_crlf.py
printf '%s\n' '=== Exception stringification in logging_config.py ==='
rg -n -C 6 '(serialization_error|type\(exc\)|str\(exc\)|\{exc\})' \
src/youtube_extension/backend/config/logging_config.pyLength of output: 51514
Do not stringify the caught exception without a second guard. Either record only The degraded-path I found no direct stdlib logging call with
|
Addresses CodeRabbit's blocking finding on #1493, which an independent red-team pass reached simultaneously. The fallback added in 1b93605 builds `f"{type(exc).__name__}: {exc}"`, which calls `exc.__str__`. If the caught exception cannot be rendered, that raises *inside* the handler meant to save the record, and `logging` drops it via `handleError` — reinstating the exact failure this PR exists to fix, one level down. Two inputs reach it, both via a value whose `__str__` raises: - an exception whose own `__str__` raises; - an exception whose `__str__` returns a non-str, so `str()` raises TypeError. Measured before the guard: both emit 2 of 3 records. After: 3 of 3. Render the reason under a nested guard and fall back to `type(exc).__name__`, which is a plain class attribute and cannot raise. The final `json.dumps` now operates only on filtered scalars plus that string, so it cannot raise. Tests: +2 (parametrized) covering both inputs. Both fail against the unguarded version and pass with it (2 failed, 23 passed -> 25 passed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LmZfJAVpuZqEzE5jc9Bmtw
Blocking finding fixed in
|
| Input | 1b93605 |
5baae24 |
|---|---|---|
exception whose own __str__ raises |
2 of 3 records | 3 of 3 |
exception whose __str__ returns a non-str (TypeError from str()) |
2 of 3 records | 3 of 3 |
The second row is the one I'd flag to a future reader: it doesn't require a raising __str__ on the exception at all, just one returning the wrong type. Your suggested repro covers the first; the second reaches the same place through TypeError.
This is exactly the defect class #1452 was filed about — a guard claiming more than it enforces — reproduced one level down in the guard itself. Which is a fair thing to have been caught on.
Fix
Took your nested-try option rather than the bare-class-name one, so the common case keeps its diagnostic message and only the unrenderable case degrades:
try:
reason = f"{type(exc).__name__}: {exc}"
except Exception: # noqa: BLE001
reason = type(exc).__name__
safe["serialization_error"] = reasontype(exc).__name__ is a plain class attribute and cannot raise, and safe now holds only filtered scalars plus that string, so the final json.dumps cannot raise either. That closes the recursion rather than moving it along.
Tests: +2, parametrized over both inputs, in the shape you asked for. Non-vacuous — both fail against the unguarded version and pass with it (2 failed, 23 passed → 25 passed). Full file: 25 passed, with the 20 pre-existing tests unchanged.
On your three other conclusions
All three match what I measured independently, and two of them answer questions I'd raised in the review request:
- No field-forgery regression on the degraded path — confirmed. I drove the fix(security): CWE-117 JSON field forgery via unescaped
"survives the #1270 log sanitizer #1429 payload through the fallback and separately put a forgery payload inside the exception message itself (so it reachesserialization_error);levelstayedINFOwith no forged field in both. - The
trystarts afterformatException/formatStack— correct, and deliberate. That was my open question 2 about blast radius, and it's the reasonexcept Exceptionhere isn't over-broad. - No direct
extra={"request_id": ...}call site — this is the one I most wanted a second pair of eyes on, since the low-severity claim ondefault=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 rests on it. Your search agreeing with mine is the evidence that claim needed.
I'm not posting @coderabbitai resolve — the re-review is rate-limited for ~47 minutes, and resolving threads before the fix has actually been re-reviewed would be dismissing a finding rather than closing it. The fix is pushed; re-review when the limit resets.
Generated by Claude Code
|
Tip For best results, initiate chat on the files or code changes.
The fallback now preserves the record when Re-review after the rate limit resets is appropriate. 🐇 ✅ ✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! You are interacting with an AI system. |
Triage note: this is not a duplicate of #1491 — please don't close it as oneFlagging this because the metadata makes it look like one, and I nearly misfiled it during a backlog sweep. Why it looks redundant: the title is byte-identical to #1491 (merged as Why it isn't: this branch carries two commits, and only the first one merged. The residual delta guards safe["serialization_error"] = f"{type(exc).__name__}: {exc}" # main - can raiseVerified independently in both directions, with an exception whose own
So the failure mode #1491 set out to close is still reachable on Two of the superseded PRs — #1477 ( Suggested: retitle to something like No code changes pushed — this is a triage note only. CI on this head is still pending. 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 bad value in a log record's optional enrichments costs that value, not the whole record.
_format_json(added by #1439, merged as27ef9e7) claimeddefault=strmeant "a record is never lost to a serialization error." It does not.defaultis consulted only for valuesjsoncannot natively encode, and it is called unguarded, so two inputs defeat it:defaultis ever reached (ValueError: Circular reference detected);__str__raises — the exception propagates straight out ofdefault.Either way
loggingswallows the raise viaHandler.handleErrorand drops the record — the exact outcome the comment said was prevented.Reproduced against
mainthrough a realStreamHandler, logging healthy → poisoned → healthy:The middle record is gone in both cases.
Reachability, stated precisely
Reachable through the
correlation_id/performance_msenrichment loop that #1439 introduced. The pre-#1439json_formattemplate referenced neither field, so this is a new failure mode rather than a pre-existing one — worth being exact about, since it is the reason this is a follow-up to #1439 and not an independent bug.It is not attacker-reachable: every live call site passes a scalar (
correlation_idcomes fromrecord.request_idand from header values inmiddleware/metrics.py, both strings), and an attacker-supplied header is a string, which is escaped correctly. A self-referential container or an exploding__str__would have to be introduced by a future call site.Severity: low. The CWE-117 field-forgery fix from #1429/#1439 is sound and unaffected.
Scope
logging_config.py— guard thejson.dumpscall, re-serialize with only the natively encodable fields, and surface the reason asserialization_error. The comment now states the guarantee the code actually provides (acceptance criterion 4).tests/unit/test_logging_config_crlf.py— +3 tests in the existing CWE-117 file.JSON_LOGGINGdefault mismatch.production_config.py:72defaults it to"true"whilelogging_config.pydefaults to"false". Scoped out of fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 for the same reason: it is a behavioural config decision, not a serialization fix.json_outputstill defaults toFalse.Risk
json.dumpsalready raises, i.e. only on records that are currently lost outright — so the worst case is a degraded record where today there is no record. The realistic risk is the fallback silently becoming a hole in fix(security): CWE-117 JSON field forgery via unescaped"survives the #1270 log sanitizer #1429's escaping guarantee; that is pinned by a dedicated test (below).git revert. No migration, config, or schema change.Verification
Head
1b93605. Measured, not inferred.Focused tests —
tests/unit/test_logging_config_crlf.py: 23 passed. The 20 pre-existing tests are unchanged and still pass, so both the fix(security): CWE-117 JSON field forgery via unescaped"survives the #1270 log sanitizer #1429 contract and the line-oriented contract are intact.Non-vacuous, and precisely so. Reverting only the
try/except(leaving the tests untouched) fails exactly the three new tests and nothing else:Both acceptance criteria reproduce and are fixed — 3 of 3 records now reach the sink for both inputs, with
levelauthoritative on the degraded record.The fallback is not a hole in fix(security): CWE-117 JSON field forgery via unescaped
"survives the #1270 log sanitizer #1429.test_serialization_fallback_still_escapes_attacker_contentdrives the forgery payload through the degraded path and assertslevelstaysINFOwith no forged field. This is the test I most wanted, because the fallback builds a second record and a naive implementation would escape it differently from the primary one.except Exceptionis deliberate, not laziness. A narrow(TypeError, ValueError, RecursionError)looks more correct and is wrong — the exploding-__str__case walks straight through it. The test above fails against the narrow version.Blast radius checked, not assumed.
tests/unit/test_logging_config_crlf.pyis the only test file in the repo that referenceslogging_config, consistent with fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439's finding thatStructuredFormatteris constructed in exactly one place (setup_logging's dictConfig).Lint —
ruff checkclean 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 handler in both directions: 2 of 3 records on
main, 3 of 3 on this head, for both the circular container and the exploding__str__. Both transcripts are above.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 explicitly to be landed after fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439, which merged at 20:48 UTC; no other open PR touches_format_jsondefault=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452: circular container emitted withlevelauthoritative; exploding__str__emitted withlevelauthoritative; regression tests in the shape of the existing ones that fail pre-fix; the comment now states the real guaranteeAgent provenance
Agent-authored. The finding was independently derived by three separate red-team passes on #1439, each of which correctly declined to push it — folding a commit in would have reset that PR's green CI, and a branch carrying #1439's diff would have read as a competing implementation of #1429. #1452 was filed so the next pass read it instead of re-deriving it a fourth time. This PR is that pass, landing now that
_format_jsonexists onmain.Generated by Claude Code