Skip to content

fix(responses): bound trailing wrapper parsing to prevent quadratic JSON.parse - #566

Open
luvs01 wants to merge 6 commits into
devfrom
codex/fix-denial-of-service-vulnerability-in-scanner
Open

luvs01 wants to merge 6 commits into
devfrom
codex/fix-denial-of-service-vulnerability-in-scanner

Conversation

@luvs01

@luvs01 luvs01 commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Motivation

  • The freeform-wrapper scanner could mark a closed object followed only by whitespace as parse: true, causing progressiveFreeformInput to call JSON.parse on the full, provider-controlled accumulated argument on every whitespace delta and produce O(n²) CPU work.
  • A malicious or compromised upstream provider can exploit this by sending many one-byte whitespace deltas after {} and cause a CPU denial-of-service against the proxy.
  • The earlier bound went too far the other way (Devin Review): once an object closed, every later whitespace-only prefix held forever, so a non-wrapper body's trailing whitespace never reached the client while completion kept it — and a close plus whitespace arriving in one chunk previewed nothing at all.

Description

  • scanFreeformWrapper now records each top-level member it already walks — decoded name plus whether its value is a string, last occurrence winning (matching JSON.parse's duplicate-key rule) — in a bounded member table.
  • afterTopLevelClose resolves a fully-seen closed object from that table: exactly one string-valued fallback field yields { kind: "input", valueStart }; anything else yields raw, so non-wrapper bodies stream byte-exact through trailing whitespace. A clamped close still holds (unseen tail).
  • progressiveFreeformInput no longer calls JSON.parse on the accumulated buffer at all — the quadratic vector is removed by construction, not rate-limited — and the parse flag on hold is gone. Fallback keys are shared via freeformFallbackKeys(toolName) from apply-patch-envelope.ts.
  • A fenced fallback value now holds like a fenced canonical input value (preview suppressed; completion authoritative), instead of publishing the stripped body at close.
  • CI fix (05fcb7a): the macOS run_macos_suite watchdog subshell orphaned its in-flight sleep on kill, keeping the step's pipes open ~15s past each suite — racing the macos-serial-lanes harness's 15s internal deadline. The sleeps now run with detached fds so an orphan costs nothing.

Testing

  • bun test tests/responses/responses-freeform-wrapper-keys.test.ts — 8 pass, 0 fail (includes byte-exact trailing-whitespace deltas asserted against unwrapFreeformToolInput, and a JSON.parse spy proving the buffer is never reparsed).
  • bun test tests/responses/responses-custom-tool-repair.test.ts tests/responses/responses-stream-consistency.test.ts, bun test tests/copilot tests/bridge.test.ts — all pass.
  • bun run typecheck, bun run structure:check, bun run privacy:scan — all pass.
  • Watchdog fix verified mechanically: detached fds → pipe EOF at ~0.5s vs 15.0s before.

Codex Task

Link to Devin session: https://app.devin.ai/sessions/987ebed6d47641c8b96b43ade5b1b761
Open in Devin Desktop: https://app.devin.ai/desktop/session/987ebed6d47641c8b96b43ade5b1b761?variant=devin
Requested by: @luvs01


Devin Review

@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: luvs01/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 5448eb00-9e84-466d-a160-bafc24a06cbd


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-20T12:52:56.888940Z 8aa3a6c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added the bug Something isn't working label Sep 20, 2026
@github-actions

Copy link
Copy Markdown

Deterministic PR hygiene checks passed.

devin-ai-integration[bot]

This comment was marked as resolved.

macos 2/2 in run 35511768704 was cancelled at the 20-minute ceiling
after the isolate running tests/codex-integration/
codex-app-server-processes.test.ts went silent mid-file: shard files
had streamed results continuously until then, then nothing for the
remaining ~17 minutes while the orphan bun process stayed alive.

A per-test --timeout cannot cover this shape: the wedge is the
runtime's own wait loop spinning inside spawnSync after the spawned
child has already exited, so the ceiling fires inside the spin and no
result line is ever written again. A suite making progress emits a
line inside every per-test window, so silence several times that
window is a wedge, not slow work. macOS has no timeout(1) to borrow
the Linux batch bound, so poll the tee'd log for growth and kill Bun
once the silence reaches five times the per-test ceiling; the shared
crash classifier already reads status 137 as process death, which is
what a wedged runner is, and the last ##[group] in the log names the
file it stalled inside.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment thread .github/workflows/ci.yml
Comment on lines +625 to +648
(
last_size=-1
silent_for=0
while sleep 15; do
size="$(wc -c < "$suite_log" 2>/dev/null || echo 0)"
if [ "$size" -le "$last_size" ]; then
silent_for=$((silent_for + 15))
else
silent_for=0
last_size=$size
fi
if [ "$silent_for" -ge "${OCX_MACOS_SUITE_SILENCE_SECONDS:-300}" ]; then
echo "::error::macOS suite produced no output for ${silent_for}s -- a wedged isolate, not slow work (several times the 60s per-test ceiling). Killing Bun; the last ##[group] in this log names the file it stalled inside."
pkill -KILL -x bun 2>/dev/null || true
# tee's read ends only when every descendant that inherited the
# merged stream is dead; a lingering grandchild must not hang the
# leg a second time, so give EOF a moment, then reap tee itself.
sleep 5
pkill -KILL -f "tee $suite_log" 2>/dev/null || true
exit 0
fi
done
) &
watchdog_pid=$!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Workflow change requires explicit security review

This watchdog changes GitHub Actions process termination and CI gating behavior. Repository policy requires explicit security review before merge.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this stays open for a human security review since it changes CI process termination.

While exercising it: the watchdog had a real defect worth noting for that review. kill $watchdog_pid reaps the subshell but orphans its in-flight sleep 15, which kept the step's output pipes open for up to 15s past each suite — the same 15s the macos-serial-lanes harness uses as its internal deadline, which is why that file timed out on every lane after this landed. Fixed in 05fcb7a by detaching the sleeps' inherited fds (</dev/null >/dev/null 2>&1), so an orphaned sleep costs nothing; verified the pipe now closes in ~0.5s vs 15.0s before.

devin-ai-integration Bot and others added 2 commits September 21, 2026 08:35
The first bound stopped the quadratic parse but went too far the other
way: once an object closed, every later whitespace-only prefix held
forever, so a non-wrapper body's trailing whitespace never reached the
client while completion kept it — and a close plus whitespace arriving
in one chunk previewed nothing at all.

The scan already walks every member, so record each top-level member's
name and string-valuedness (last occurrence winning, JSON.parse's own
duplicate rule) and consult the table once at the close: exactly one
string fallback field is the wrapper's value, anything else streams the
raw text byte-exact through its trailing whitespace. progressiveFreeform
Input never calls JSON.parse on the accumulated buffer anymore — the
classification is entirely the scan's own bounded work — and the 'parse'
flag on hold is gone with it.

A fenced fallback value now holds like a fenced canonical one instead of
publishing the stripped body at close; completion is unchanged either way.

Co-Authored-By: Epinephrine <luvs01@hanmail.net>
… step's pipes

kill on the watchdog subshell reaps the loop but not its in-flight
sleep, which is orphaned with the suite's output fds still inherited.
Each suite then held the step's pipes open for up to fifteen seconds
past its end — the same fifteen seconds the macos-serial-lanes harness
uses as its internal deadline, which is what timed the file out on
every lane. The sleeps run detached so an orphaned one costs nothing.

Co-Authored-By: Epinephrine <luvs01@hanmail.net>
@devin-ai-integration

Copy link
Copy Markdown

Fixed in d9dc208. Rather than carrying classification across deltas, the scan now records each top-level member it already walks — name plus whether its value is a string, last occurrence winning (JSON.parse's duplicate rule) — and afterTopLevelClose consults that table instead of reparsing: exactly one string-valued fallback field resolves to the wrapper's input (its recorded valueStart), anything else is raw and streams byte-exact through the trailing whitespace. progressiveFreeformInput no longer calls JSON.parse on the accumulated buffer at all, so the quadratic vector is gone by construction rather than by rate-limiting.

This also covers the worse variant the hold introduced: a close plus trailing whitespace arriving in one chunk previewed nothing at all; it now resolves correctly. The regression test is extended per your suggestion — concatenated deltas are asserted equal to unwrapFreeformToolInput for raw objects with trailing whitespace (fragmented and single-chunk), and for fallback wrappers where the whitespace is correctly absent; a JSON.parse spy confirms the buffer is never reparsed.

One deliberate side effect worth noting: a fenced fallback value now holds like a fenced canonical input value does (preview suppressed, completion authoritative), instead of publishing the stripped body at close.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment thread src/responses/freeform-wrapper-scan.ts
@devin-ai-integration

Copy link
Copy Markdown

Already updated — the description now describes the member-table classification and byte-exact raw streaming, plus the watchdog fd fix.

@devin-ai-integration

Copy link
Copy Markdown

E2E verification — freeform tool-call streaming (live proxy + mock upstream)

Drove streamed exec freeform tool calls through a live proxy (bun run src/cli/index.ts start, :10100) with a credential-free mock upstream emitting fragmented function_call_arguments.delta / chat tool_calls SSE on :19191. Upstream request log confirms the client's {"type":"custom","name":"exec"} was lowered to {"type":"function","name":"exec"} on the responses lane and a chat tools[].function on the bridge lane.

e2e evidence

Result: 7/7 strict byte-exact assertions pass{"code":"cmd"} → delta "cmd"; {"a":1} → delta byte-exact incl. trailing whitespace; fenced fallback {"code":"```\nconst x=1;\n```"} holds until .done"const x=1;"; canonical {"input":"…"} unwrap unchanged. No response.failed/response.incomplete events in any capture.

Differential detail (vs baseline b9d430b and intra-PR 8aa3a6c)
  • Baseline proxy (pre-PR dev) produced identical client-visible output for all cases — the fix preserves behavior; the shipped head differs only in CPU cost.
  • The held-forever regression (zero deltas when } + whitespace share a buffer) reproduced only at the intermediate commit 8aa3a6c and is resolved by d9dc208 — confirmed via function-level replay of the same fragment streams.
  • DoS demonstration (live proxies, {"a":1} + N single-whitespace deltas): N=160k → head 6.8s vs baseline 27.1s (~4×, quadratic JSON.parse of the accumulated buffer eliminated).
  • Passthrough lane emitting zero deltas for raw {-objects is intentional routed hold until .done; byte-exact whitespace streaming is observable on the bridge lane — both lanes were exercised.

Tested via a Devin session.

codex-retained-root-serialization.test.ts' post-approval seam test timed out
at the 45s budget when one spawned child never exited (swept as a dangling
process) and the other exited non-zero with empty stderr -- neither a repo
code change nor a recognized lock-contention signature. Verified locally:
7/7 pass in ~5s on the merge commit.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aardvark bug Something isn't working codex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant