fix(logging): keep the record when JSON serialization raises - #1471
fix(logging): keep the record when JSON serialization raises#1471groupthinking wants to merge 2 commits into
Conversation
Closes #1452 `_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 still escape `json.dumps`: - a circular container is rejected structurally *before* `default` is consulted, so `default=str` never sees it; - a value whose `__str__` raises propagates out of `default` itself. Either way `logging` swallows the raise via `Handler.handleError` and drops the record. Measured on `main` with three `logger.info` calls through a real StreamHandler, the middle one poisoned: 2 of 3 records reached the sink, in both cases. With this change, 3 of 3. Reachable through `extra={"request_id": ...}`, which `format()` copies to `correlation_id` and the enrichment loop pulls into the payload. Not a regression -- the pre-#1439 `json_format` template never referenced `correlation_id`, so the input was unreachable on the JSON path before it -- and not reachable from request content, since headers arrive as strings and strings serialize correctly. Low severity: a robustness gap plus a comment that promised more than the code delivered. The fallback retains only natively-serializable scalars, which is every field except the two optional enrichments the call site controls, and records the cause in `serialization_error` rather than dropping it silently. It re-serializes with `json.dumps`, so the CWE-117 property from #1429 holds on this path too -- covered by a test rather than assumed. `except Exception`, not `(TypeError, ValueError, RecursionError)`: the narrow tuple looks more correct and misses the exploding-`__str__` case outright (verified -- it raises RuntimeError). The error-detail string is guarded too, for an exception whose own `__str__` raises. Verification (measured, not inferred): - `tests/unit/test_logging_config_crlf.py`: 24 passed. - Non-vacuous: against `main`'s `_format_json`, all 4 new tests fail (`4 failed, 20 passed`). The 20 pre-existing tests pass either way, so the line-oriented path and the #1429 fix are untouched. - `ruff check src/youtube_extension/backend/ src/youtube_extension/main.py` run exactly as CI does: `Found 2 errors` before and after -- byte-identical, both pre-existing. Clean on both changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpTcrsgVsqbGV8dmPZadoe
|
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 with the unsatisfiable label gate. That is #1424, which is reopened and already escalated — this PR is one more instance, not new information, so I'm not commenting there. Requesting by hand, which does work. This is a guard on an error path, so the claims worth attacking are narrow and specific:
One thing I'd rather you catch than take on trust: Note for scope: Generated by Claude Code |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== PR diff summary =='
git diff --stat HEAD^ HEAD
echo
echo '== Changed-file diff =='
git diff --unified=80 HEAD^ HEAD -- \
src/youtube_extension/backend/logging_config.py \
tests/unit/test_logging_config_crlf.py
echo
echo '== Candidate logging config files =='
fd -a -t f 'logging_config\.py$|production_config\.py$' .
echo
echo '== _format_json outline =='
config_file="$(fd -a -t f 'logging_config\.py$' . | head -n1)"
if [ -n "$config_file" ]; then
ast-grep outline "$config_file" --items all --match '_format_json|format' --view expanded || true
echo
echo "== _format_json and formatter context: $config_file =="
rg -n -A100 -B30 'def _format_json\b|def format\b|json\.dumps\b|sanitize_log_record\b' "$config_file"
fi
echo
echo '== References to JSON logging formatter and enrichment fields =='
rg -n -A4 -B4 --glob '*.py' \
'(_format_json\b|correlation_id\b|performance_ms\b|JSON_LOGGING\b|sanitize_log_record\b)' \
src tests 2>/dev/null || trueLength of output: 42298
The first Use exact runtime types in the fallback filter. The first serialization remains the only path for records that serialize successfully. The fallback still uses 🐇
|
Addresses CodeRabbit's blocking finding on #1471. The fallback filtered with `isinstance(value, (str, int, float, bool, type(None)))`. `isinstance` consults `value.__class__`, which an object can forge as a property returning `str`. Such a value passes the filter, reaches the fallback's `json.dumps` -- which deliberately has no `default=` -- and raises `TypeError`. The record is lost, which is the exact failure the fallback exists to prevent. Verified rather than taken on trust: isinstance(o, str) : True type(o) in {str, ...} : False first dumps : raised RuntimeError (fallback triggers) retained by isinstance : ['level', 'correlation_id'] second dumps (isinstance) : raised TypeError -> RECORD LOST retained by exact type : ['level'] second dumps (exact type) : {"level": "INFO"} `type(value) in {...}` cannot be forged, so it enforces the invariant the comment already claimed -- "only natively-serializable scalars are retained" -- rather than asserting it. That is the same defect class this PR is about: a guard whose stated guarantee the code did not deliver. Adding `default=str` to the fallback dump would also stop the TypeError, and is the wrong fix: it reinstates the raising-`__str__` hole the first dump already demonstrated. Nothing may reach `default` on the fallback path. `except Exception` is unchanged and stays correct -- it excludes `BaseException` subclasses such as `KeyboardInterrupt` and `SystemExit`. Verification: - `tests/unit/test_logging_config_crlf.py`: 25 passed (was 24). - Non-vacuous, and precisely so: reverting only the filter line to the `isinstance` form fails exactly `test_fallback_filter_is_not_fooled_by_a_ forged_class` and nothing else. - `ruff` clean on both changed files; the CI invocation reports the same 2 pre-existing errors as `main`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpTcrsgVsqbGV8dmPZadoe
Finding confirmed and fixed in
|
|
Tip For best results, initiate chat on the files or code changes.
The decision not to add The focused regression test also pins the required invariant. No further action is needed on this finding. 🐇 ✅ ✏️ 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. |
|
Duplicate of #1477 — three PRs (#1471, #1472, #1477) implement #1452, all created within three minutes of each other. The diagnosis here is correct and the fix works; this is purely a reconciliation call under Functionally the three are equivalent — your fallback omits Recommending close in favour of #1477. Full comparison and verification evidence is there. 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
Your forged-
|
|
Superseded — #1452 landed as #1491 while this was open, so the base fix here is now on The part of this PR that is not on main is real and worth keeping: the Carried into #1497 along with the other unmerged findings from #1472/#1477/#1488. 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
Follow-up to #1439, landed as that issue specified:
_format_jsondid not exist onmainuntil #1439 merged at 20:48 UTC, so this could not be written earlier without reading as a competing implementation of #1429. It is branched frommainafter that merge and carries none of #1439's diff.Outcome
A log record is no longer lost when
json.dumpsraises inside the logging path._format_jsonclaimeddefault=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 still escape:defaultis consulted —default=strnever sees it;__str__raises propagates out ofdefaultitself.Either way
loggingswallows the raise viaHandler.handleErrorand drops the record silently.Reproduced against
main(f96601b) before changing anything — threelogger.infocalls through a realStreamHandler, the middle one poisoned:After this change, 3 of 3 in both cases.
Scope
logging_config.py— guardedjson.dumpsfallback in_format_json, and the comment that promised more than the code delivered.tests/unit/test_logging_config_crlf.py— +4 tests in the existing CWE-117 file."survives the #1270 log sanitizer #1429/fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 is sound and unaffected; the fallback re-serializes withjson.dumps, so that property holds on this path too — asserted by a test rather than assumed.json_outputstill defaults toFalseand that branch still appliessanitize_log_record.JSON_LOGGINGdefault mismatch betweenproduction_config.py:72("true") andlogging_config.py("false"). Still a separate behavioural config decision, as fix(security): CWE-117 JSON field forgery via unescaped"survives the #1270 log sanitizer #1429 recorded.Risk
exceptpath, so it cannot alter any record that serializes today — the happy path is the samejson.dumpscall it always was. The realistic risk is the fallback itself raising; it retains only natively-serializable scalars and guards the error-detail string, so it has no unserializable input left to choke on.git revert. No migration, config, or schema change. The emitted field set is unchanged except forserialization_error, which appears only on records that would previously have been dropped entirely.Reachable through
extra={"request_id": ...}, whichformat()copies tocorrelation_idand the enrichment loop pulls into the payload. Not a regression — the pre-#1439json_formattemplate never referencedcorrelation_id, so this input was unreachable on the JSON path before it. Not reachable from request content — headers arrive as strings, and strings serialize correctly. A self-referential container or an exploding__str__would have to be introduced by a future call site.Verification
Head
e2393fe. Measured, not inferred.Focused tests —
tests/unit/test_logging_config_crlf.py: 24 passed.Non-vacuous. Against
main's_format_json, all four new tests fail:The 20 pre-existing tests pass either way, so the line-oriented contract and the fix(security): CWE-117 JSON field forgery via unescaped
"survives the #1270 log sanitizer #1429 fix are demonstrably untouched.except Exceptionis required, not stylistic. The narrower(TypeError, ValueError, RecursionError)looks more correct and misses the exploding-__str__case outright — verified directly:narrow tuple MISSED -> RuntimeError. Recorded because the wrong version is the more attractive one.Lint —
ruff check src/youtube_extension/backend/ src/youtube_extension/main.py --ignore E402,F811,F401,F821,B904,B020,E701,E722, run exactly as CI does:Found 2 errorsbefore and after, byte-identical, both pre-existing. Clean on both changed files individually.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 above, run against the real formatter through a real handler in both directions — 2 of 3 records on
main, 3 of 3 on this head. Exposure is bounded rather than theoretical:production_config.py:72defaultsJSON_LOGGINGto"true", so the JSON path is the production path, but no current call site passes a non-scalar.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 is newly filed and unclaimed; this branch is cut frommainafter fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 merged and carries none of its diffdefault=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 on the pre-fix implementation; the comment now states the guarantee the code actually providesAgent provenance
Agent-authored under the PR remediation runbook. The finding was not mine — it was derived independently by three red-team passes on #1439 (comments 5221126104, 5221344113, 5221378002), each of which correctly declined to push it mid-review, and was then filed as #1452 so the fourth pass would read it instead of re-deriving it a fourth time. This is that fourth pass reading it.
Generated by Claude Code