Skip to content

fix(security): scan both ends of an oversized body, not just the front - #13104

Merged
diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.51from
ntdatt812:fix/injection-scan-window
Sep 10, 2026
Merged

diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.51from
ntdatt812:fix/injection-scan-window

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

The bug

detectInjection() caps the scan at the first 16 KB. The budget is deliberate (hot-path perf, #3932 / #4041) and this PR keeps it exactly. Which bytes it spends is the problem.

extractMessageContents() appends system, input, prompt, instructions, query and documents after the message list. So a single long message pushes all six past the cap — along with the newest turns.

The comment beside the slice states the assumption:

Injection directives sit near the top; scanning the full join buys only CPU/GC.

The extractor puts the highest-signal carriers at the bottom.

Measured on release/v3.8.51

~30 KB of ordinary conversation — a small body for a coding agent — with one payload moved between carriers. Same payload scores 2 in a short body.

injection lives in detections before after
system 0 2
instructions 0 2
query 0 2
documents 0 2
the newest turn 0 2
the oldest turn 2 2

With INPUT_SANITIZER_MODE=block, the same sentence is a 400 in a fresh chat and a pass-through once the conversation passes 16 KB.

The change

Spend half the budget at each end.

The tail is where content that has never been scanned before lives: those six carriers, and the turn that was just added. An injection in an older turn was the newest turn on an earlier request, and was scanned then. The head half keeps the front-of-conversation coverage the current slice provides.

Two details:

  • A separator that breaks \s+ sits between the halves. Every INJECTION_PATTERN joins its words with \s+, so a bare newline would let …ignore all previous at the end of the head and instructions… at the start of the tail match as one phrase they never formed.
  • The separator is charged to the budget, so the pattern loop still sees at most MAX_INJECTION_SCAN_BYTES characters. No extra regex work, no extra bytes.

evaluatePromptInjection() re-derived the same prefix slice for custom patterns; it now shares the helper, so custom and built-in patterns cannot disagree about what was scanned.

This is a trade, and worth saying plainly: the second 8 KB of the oldest messages is no longer scanned. That region is conversation that earlier requests already scanned; the end of the body is not.

Tests

tests/unit/guardrails/injection-scan-window.test.ts, 12 tests, driven through sanitizeRequest and evaluatePromptInjection as well as the helper. The first test asserts the filler is both past the cap and clean, so no case below can pass for the wrong reason.

Three mutations:

mutation result
back to a prefix-only scan 7 fail
separator becomes a bare newline 1 fail (the seam test)
the gap is not charged to the budget 1 fail (the budget test)

The seam test calibrates head/gap/tail lengths from buildInjectionScanText itself — the first version used fixed offsets, both phrases landed inside the head half, and it survived the newline mutation. It is in its own commit so that is visible.

Existing suites — guardrails/injection-extraction, guardrails/injection-route-coverage, env-docs-input-sanitizer-8093, chatcore-sanitization, guardrails-registry, guardrails-api-3496, guardrails-void-no-change-contract, injection-guard-nonchat-route-logging: 54 passed. eslint --suppressions-location config/quality/eslint-suppressions.json clean.

Independent of #13101, which fixes a carrier the extractor never collected at all; these touch different parts of the file and either can land first.

detectInjection() caps the scan at the first 16 KB. The budget is deliberate
(hot-path perf, cheaper-inference#3932 / cheaper-inference#4041) and this keeps it exactly. Which bytes it
spends is the problem.

extractMessageContents() appends `system`, `input`, `prompt`, `instructions`,
`query` and `documents` after the message list. So one long message pushes all
six past the cap, along with the newest turns. Measured on release/v3.8.51
with ~30 KB of ordinary conversation -- a small body for a coding agent -- and
one payload moved between carriers:

  injection in system          -> 0 detections
  injection in instructions    -> 0
  injection in query           -> 0
  injection in documents       -> 0
  injection in the newest turn -> 0

The same payload in a short body scores 2. The comment beside the slice says
"injection directives sit near the top", but the extractor itself puts the
highest-signal carriers at the bottom.

Spend half the budget at each end. The tail is where content that has never
been scanned before lives: those six carriers, and the turn just added -- an
injection in an older turn was already the newest turn on an earlier request.
A separator that breaks \s+ goes between the halves so no pattern can match
across a boundary the two never shared, and it is charged to the budget so the
loop still sees at most MAX_INJECTION_SCAN_BYTES characters.

evaluatePromptInjection() re-derived the same prefix slice for custom
patterns; it now shares the helper, so custom and built-in patterns cannot
disagree about what was scanned.
The first version placed its two phrases at fixed offsets, so after the
budget was split they both landed inside the head half and the test passed
against a bare-newline separator too -- it proved nothing.

Calibrate the head, gap and tail lengths from buildInjectionScanText itself,
put the phrases flush against the seam, and keep the space that gives
\b(ignore| its word boundary. Replacing the separator with a newline now
fails this test.
@diegosouzapw
diegosouzapw merged commit 751247a into cheaper-inference:release/v3.8.51 Sep 10, 2026
9 of 16 checks passed
diegosouzapw added a commit that referenced this pull request Sep 11, 2026
Four base-reds that appeared on the tip today, none owned by any open PR:

- providers-constants-split / provider-node-reserved-prefix: GreenPT (#13024)
  and EURouter (#13025) move the totals 238 -> 240 and 408 -> 410.
- provider-translate-path-golden: same two providers, regenerated with
  UPDATE_GOLDEN=1; the snapshot delta is purely additive.
- glm-sse-transform-arity: #12925 declared the buffer-size slot GLM had been
  filling since #12179. The invariant is unchanged — the value must land in a
  real parameter, never a dropped positional — so the guard now checks it from
  the helper side and requires the named constant at the call site instead of
  a magic literal.
- injection-guard-scan-bound-3932: #13104 split the 16 KB budget between head
  and tail, so 'outside the window' is now the middle of an oversized body,
  not its end. The 16 KB ceiling these cases protect is untouched.

Co-authored-by: diegosouzapw <[email protected]>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
cheaper-inference#13104)

Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings.

Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants