1
0
Fork 0

feat(sanitize,fence,neutralize): reject oversize input instead of half-transforming it

The scanners cap by truncating: they return findings, so reading a prefix costs
detection in the tail and nothing else. The three transform surfaces return
*content*, where the same move is not available — a shortened document is silent
data loss, and a transformed prefix followed by an untransformed tail is a
bypass, since the attacker chooses where in the document the payload sits.

So they fail secure instead. Above MAX_INPUT_CHARS (1 000 000) sanitize, fence
and neutralize raise OversizeInputError. sanitize is step 1 of prepare_input and
only ever removes, so that one refusal bounds the whole input path.

OversizeInputError subclasses ContractViolation: a pipeline already bracketing
its quarantined stage keeps failing closed rather than meeting a type it has
never heard of. It inherits the alert-routable property too — sizes in the
message, refusing surface in details, no input in either.

Invariant now pinned across all three: returned text is always fully
transformed, or not returned at all.

Still uncapped and recorded in LIMITATIONS: scan_active_content called directly
(through scan_output it inherits that cap) and the okf link graph. Both are
detection-shaped, so truncate-and-flag transfers unchanged — mechanical, not
policy.

699 tests (+23), coverage 128/128 + 6/6, ReDoS sweep 0 candidates / 150.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 21:13:08 +02:00
commit 2d98d6809d
10 changed files with 272 additions and 21 deletions

View file

@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
> **Behaviour change, not a pure fix.** The three transform surfaces gain a
> refusal path they did not have. A caller that today passes a document larger
> than 1 000 000 characters gets an exception where it previously got a result.
> Both pinned consumers were measured against this before it was written; git
> pins are exact, so nobody picks it up without re-pinning.
### Added — input-size cap on the transform surfaces (OWASP LLM10)
`sanitize`, `fence` and `neutralize` now raise `OversizeInputError` above
`MAX_INPUT_CHARS` (1 000 000) instead of accepting text of any length. Since
`sanitize` is step 1 of `prepare_input` and only ever *removes*, that single
refusal bounds the whole input path.
They **reject** where the scanners **truncate**, and the asymmetry is the point:
- `scan_lexicon` / `scan_output` return findings. Reading a prefix costs
detection in the tail and nothing else — a lossy answer, but an answer.
- `sanitize` / `fence` / `neutralize` return *content*. Truncating would return
a shortened document (silent data loss for anything that persists the result)
or a transformed prefix followed by an untransformed tail — a bypass, since an
attacker chooses where in the document the payload sits.
The invariant the three now keep: **returned text is always fully transformed,
or not returned at all.**
`OversizeInputError` subclasses `ContractViolation`, so a pipeline already
bracketing its quarantined stage in `except ContractViolation` keeps failing
closed. Like its parent it is alert-routable: the message carries the size and
the cap, `details` names the refusing surface, and neither carries input.
`max_input_chars` is a per-call parameter, defaulting to the single calibrated
constant.
### Still uncapped, and deliberately
`scan_active_content` **called directly** and the okf link graph. Reached through
`scan_output`, `scan_active_content` inherits that function's cap. Both are
detection-shaped, so the truncate-and-flag mechanism transfers to them unchanged
— mechanical follow-up work, not a policy question. Recorded in
`docs/LIMITATIONS.md`.
## [0.3.4] — 2026-08-01 ## [0.3.4] — 2026-08-01
> **Denial-of-service fix on the INPUT path. Upgrade from 0.3.3.** 0.3.3 swept > **Denial-of-service fix on the INPUT path. Upgrade from 0.3.3.** 0.3.3 swept

View file

@ -3,7 +3,7 @@
![Version](https://img.shields.io/badge/version-0.3.4-blue) ![Version](https://img.shields.io/badge/version-0.3.4-blue)
![Status](https://img.shields.io/badge/status-alpha-orange) ![Status](https://img.shields.io/badge/status-alpha-orange)
![Python](https://img.shields.io/badge/python-3.10%2B-purple) ![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![Tests](https://img.shields.io/badge/tests-676_passing-green) ![Tests](https://img.shields.io/badge/tests-699_passing-green)
![License](https://img.shields.io/badge/license-MIT-lightgrey) ![License](https://img.shields.io/badge/license-MIT-lightgrey)
**Write-time ingestion is the trust boundary that query-time guardrails **Write-time ingestion is the trust boundary that query-time guardrails
@ -90,6 +90,15 @@ the disposition is `FAIL_SECURE`, never a silent persist. Pass
together with a transform failure is treated as a probable forced-fallback attack together with a transform failure is treated as a probable forced-fallback attack
and halts regardless of trust tier. and halts regardless of trust tier.
`prepare_input` fails **closed** on size too: above `MAX_INPUT_CHARS`
(1 000 000) it raises `OversizeInputError`, a `ContractViolation` subclass, rather
than returning a half-sanitized document. The scanners bound their work by
reading a prefix and flagging, which costs only detection in the tail; a
transform returns *content*, where the same move would either drop your data
silently or hand back an untransformed tail — the exact place an attacker would
put the payload. Catch it where you catch your other ingest refusals; the
exception carries sizes and the refusing surface, never any of the input.
Every primitive is also exported for pipelines that compose the checklist Every primitive is also exported for pipelines that compose the checklist
themselves — `sanitize`, `scan_lexicon`, `scan_entropy`, `scan_output`, themselves — `sanitize`, `scan_lexicon`, `scan_entropy`, `scan_output`,
`scan_active_content`, `neutralize`, the `decide` / `guard` disposition `scan_active_content`, `neutralize`, the `decide` / `guard` disposition

View file

@ -302,19 +302,20 @@ items; this is the full list, each with the mechanism.
*closes* around a long body, and a run of plain characters carrying no anchor *closes* around a long body, and a run of plain characters carrying no anchor
at all. at all.
- **Only `scan_lexicon` and `scan_output` cap their input; the input-side entry - **Two detection surfaces still accept unbounded input; the transform surfaces
points do not.** `MAX_SCAN_CHARS` (1 000 000) is applied in those two functions no longer do.** Since 0.3.5 `sanitize`, `fence` and `neutralize` raise
only. `sanitize`, `fence`, `neutralize`, `scan_active_content` and the okf link `OversizeInputError` above `MAX_INPUT_CHARS` (1 000 000) rather than returning
graph accept text of any length, so their cost is bounded by the caller's a partially transformed document, which bounds the whole input path — `sanitize`
input, not by this library. Every *known* quadratic run on those paths is is step 1 of `prepare_input`, and it only ever removes, so everything after it
fixed, and the residual above states what the sweep can and cannot claim — but is already under the cap. They reject rather than truncate because they return
where an output-path residual is capped at ~23 s, the same residual on the *content*: a shortened document is silent data loss, and a transformed prefix
input path has no ceiling. A caller that ingests untrusted documents of followed by an untransformed tail is a bypass an attacker positions the payload
unbounded size should impose its own limit before `prepare_input`. Extending into. The scanners keep truncating, which costs only detection in the tail.
the cap into the input path is deliberately **not** done as part of a ReDoS What remains uncapped is `scan_active_content` **called directly** (reached
patch: it changes the contract for existing callers (what happens to the through `scan_output` it inherits that cap) and the okf link graph, whose cost
truncated remainder is a policy question), and that deserves its own decision is a bundle-wide `findall` over every document body. Both are detection-shaped,
rather than being smuggled in. so the scanners' truncate-and-flag mechanism transfers to them unchanged — that
is a mechanical follow-up, not a policy question, and it is not yet done.
## The six documented gaps (tracked by the coverage matrix) ## The six documented gaps (tracked by the coverage matrix)

View file

@ -48,9 +48,11 @@ from .disposition import (
from .contract import ( from .contract import (
assert_tool_less, assert_tool_less,
assert_credential_allowlist, assert_credential_allowlist,
assert_within_input_cap,
credential_env_names, credential_env_names,
scoped_env, scoped_env,
ContractViolation, ContractViolation,
OversizeInputError,
) )
from .grounding import ( from .grounding import (
SourceGroundingCheck, SourceGroundingCheck,
@ -140,6 +142,7 @@ __all__ = [
# contract asserters # contract asserters
"assert_tool_less", "assert_credential_allowlist", "assert_tool_less", "assert_credential_allowlist",
"credential_env_names", "scoped_env", "ContractViolation", "credential_env_names", "scoped_env", "ContractViolation",
"assert_within_input_cap", "OversizeInputError",
# grounding seam # grounding seam
"SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK", "SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK",
# §6 bookends # §6 bookends

View file

@ -47,6 +47,17 @@ ENTROPY_HEX_FLOOR_LEN = 64
# measured to do before the ReDoS fix (see active_content's pattern-table note). # measured to do before the ReDoS fix (see active_content's pattern-table note).
MAX_SCAN_CHARS = 1_000_000 MAX_SCAN_CHARS = 1_000_000
# --- transform surfaces: input cap ------------------------------------------
# The same size, and deliberately NOT the same constant, because the two caps
# buy different things and may need to move apart. MAX_SCAN_CHARS truncates: a
# scanner returns findings, so reading the prefix costs *detection* on the tail
# and nothing else. `sanitize` / `fence` / `neutralize` return content, so the
# equivalent move would hand back either a shortened document (silent data loss)
# or an untransformed tail (a bypass an attacker positions the payload into).
# They reject instead — see contract.OversizeInputError. Held at 1M so a
# document accepted by the input path is one the scanners can also read whole.
MAX_INPUT_CHARS = 1_000_000
# --- output: secret-egress self-safety -------------------------------------- # --- output: secret-egress self-safety --------------------------------------
# Longest password a connection-string pattern will match. A bound is required # Longest password a connection-string pattern will match. A bound is required
# (not merely nice) because the run sits in front of a mandatory `@`: unbounded, # (not merely nice) because the run sits in front of a mandatory `@`: unbounded,

View file

@ -18,6 +18,12 @@ Three asserts, matching the reusable-contract checklist (BRIEF §6, steps 3-4):
env so a hijacked stage cannot even read a credential it was never granted; env so a hijacked stage cannot even read a credential it was never granted;
the assert then passes by construction. the assert then passes by construction.
A fourth asserter lives here for the same reason it enforces rather than
reports though it belongs to the transform path rather than the quarantine
checklist: **(d) bounded transform input**, :func:`assert_within_input_cap`,
which the three content-returning surfaces call so an oversize document is
refused instead of half-transformed.
Reference: ``claude-code-llm-wiki`` ``tools/wiki_ingest/enrich.py`` Reference: ``claude-code-llm-wiki`` ``tools/wiki_ingest/enrich.py``
``assert_quarantine`` a pipeline-specific quarantine gate, generalized here ``assert_quarantine`` a pipeline-specific quarantine gate, generalized here
into framework-agnostic, reusable pieces. into framework-agnostic, reusable pieces.
@ -51,6 +57,40 @@ class ContractViolation(Exception):
self.details = details self.details = details
class OversizeInputError(ContractViolation):
"""A transform surface was handed more text than it will transform.
A *subclass*, not a sibling: a pipeline that already brackets its quarantined
stage in ``except ContractViolation`` keeps failing closed rather than
meeting an exception type it has never heard of. :attr:`details` names the
surface that refused; the sizes go in the message, and neither carries any
of the input, so the exception stays alert-routable like its parent.
"""
def assert_within_input_cap(text: str, *, surface: str, max_input_chars: int) -> None:
"""Raise :class:`OversizeInputError` unless ``text`` fits the transform cap.
The write-time asserter for the *transform* surfaces (d). Where the scanners
bound their work by truncating findings are lossy in the tail and nothing
else a transform returns content, so a prefix-only result is either silent
data loss or an untransformed tail the payload can be positioned into. The
invariant these three keep instead is: returned text is always fully
transformed, or not returned at all.
``max_input_chars`` is the largest accepted size, not the smallest rejected
one.
"""
size = len(text)
if size > max_input_chars:
raise OversizeInputError(
f"{surface}: input {size} chars exceeds cap {max_input_chars}; "
"refused rather than partially transformed",
code="oversize-input",
details=(surface,),
)
# --- (a) tool-less transform ----------------------------------------------- # --- (a) tool-less transform -----------------------------------------------
# Populated tool surface across Anthropic + OpenAI request shapes. An empty # Populated tool surface across Anthropic + OpenAI request shapes. An empty

View file

@ -24,6 +24,8 @@ import re
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source from .report import Finding, Report, Severity, Source
# Static delimiter skeleton. The per-call nonce is the security boundary; this # Static delimiter skeleton. The per-call nonce is the security boundary; this
@ -59,9 +61,21 @@ def _redact(s: str, show_start: int = 16, show_end: int = 5) -> str:
return f"{s[:show_start]}...{s[-show_end:]}" return f"{s[:show_start]}...{s[-show_end:]}"
def fence(text: str, source: Source = Source.INPUT) -> FenceResult: def fence(
text: str,
source: Source = Source.INPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> FenceResult:
"""Strip fabricated fence markers from ``text``, then wrap it in a """Strip fabricated fence markers from ``text``, then wrap it in a
randomized, unspoofable delimiter.""" randomized, unspoofable delimiter.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``. Reached through :func:`prepare_input` the text is
already bounded (``sanitize`` only ever removes), so the guard matters for
direct callers and there it is load-bearing: a fence whose *body* was
truncated would present unstripped attacker markers as fenced data.
"""
assert_within_input_cap(text, surface="fence", max_input_chars=max_input_chars)
report = Report() report = Report()
# 1. Strip attacker fence markers FIRST — otherwise a lucky guess of the # 1. Strip attacker fence markers FIRST — otherwise a lucky guess of the

View file

@ -53,6 +53,8 @@ from .active_content import (
is_active_tag, is_active_tag,
redact, redact,
) )
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source from .report import Finding, Report, Severity, Source
@ -64,13 +66,23 @@ class NeutralizeResult:
report: Report report: Report
def neutralize(text: str, source: Source = Source.OUTPUT) -> NeutralizeResult: def neutralize(
text: str,
source: Source = Source.OUTPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> NeutralizeResult:
"""Defang active-content constructs in ``text`` and report each class. """Defang active-content constructs in ``text`` and report each class.
Rewrites markdown images/links, reference-link definitions, angle-bracket Rewrites markdown images/links, reference-link definitions, angle-bracket
autolinks, raw active HTML, and ``data:`` URIs into inert forms. Text with no autolinks, raw active HTML, and ``data:`` URIs into inert forms. Text with no
such construct is returned byte-identical with an empty report. such construct is returned byte-identical with an empty report.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``. A partially defanged artifact is the worst outcome
available here: it *looks* neutralized, and the live constructs are all in
the tail nobody re-reads.
""" """
assert_within_input_cap(text, surface="neutralize", max_input_chars=max_input_chars)
report = Report() report = Report()
out = text out = text

View file

@ -15,6 +15,8 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source from .report import Finding, Report, Severity, Source
# Invisible / steganographic character classes (codepoints). # Invisible / steganographic character classes (codepoints).
@ -29,8 +31,10 @@ _TAG_LO, _TAG_HI = 0xE0000, 0xE007F # Unicode Tags block (U+E0000U+E007F)
# was wrong in the same way `output`'s was: a lazy run in front of a REQUIRED # was wrong in the same way `output`'s was: a lazy run in front of a REQUIRED
# literal costs a full tail rescan at *every* start position when the literal # literal costs a full tail rescan at *every* start position when the literal
# never arrives, so `<!--` repeated to 100_000 chars measured 20.1s (exponent # never arrives, so `<!--` repeated to 100_000 chars measured 20.1s (exponent
# 1.962.14 over four doublings) — and this module, unlike `scan_lexicon` / # 1.962.14 over four doublings) — and at the time this module, unlike
# `scan_output`, applies no input cap, so nothing bounds that above. # `scan_lexicon` / `scan_output`, applied no input cap, so nothing bounded that
# above. MAX_INPUT_CHARS now does, but as a second line only: the cap bounds a
# *future* quadratic pattern's damage, it does not make a quadratic one safe.
# #
# Neither of the two fixes used elsewhere fits here. Excluding the opener (`<`) # Neither of the two fixes used elsewhere fits here. Excluding the opener (`<`)
# from the run would drop every comment containing markup — `<!-- <b>x</b> -->` # from the run would drop every comment containing markup — `<!-- <b>x</b> -->`
@ -94,8 +98,19 @@ def _decode_tags(codepoints: list[int]) -> str:
return "".join(out) return "".join(out)
def sanitize(text: str, source: Source = Source.INPUT) -> SanitizeResult: def sanitize(
"""Strip carrier classes from ``text`` and report per-class counts.""" text: str,
source: Source = Source.INPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> SanitizeResult:
"""Strip carrier classes from ``text`` and report per-class counts.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``: this is step 1 of the input path, so the refusal bounds
the whole path, and a *partially* sanitized document is worse than none
the unstripped tail is where a carrier would be placed.
"""
assert_within_input_cap(text, surface="sanitize", max_input_chars=max_input_chars)
report = Report() report = Report()
# Character-class carriers: single pass, keep everything else verbatim. # Character-class carriers: single pass, keep everything else verbatim.

105
tests/test_input_cap.py Normal file
View file

@ -0,0 +1,105 @@
"""Tests for the input-size cap on the TRANSFORM surfaces (OWASP LLM10).
The scan surfaces (``scan_lexicon`` / ``scan_output``) cap by *truncating*: they
read the prefix, emit an ``oversize-input`` finding, and return findings only.
That trade does not transfer here. ``sanitize`` / ``fence`` / ``neutralize``
return **content**, so truncating would either drop user data silently or hand
back a tail that never went through the transform a one-line bypass, since an
attacker controls where in the document the payload sits.
So these three fail *secure* instead. The invariant under test:
returned text is ALWAYS fully transformed, or not returned at all.
"""
import inspect
import pytest
from llm_ingestion_guard import prepare_input
from llm_ingestion_guard.calibration import MAX_INPUT_CHARS
from llm_ingestion_guard.contract import ContractViolation, OversizeInputError
from llm_ingestion_guard.fence import fence
from llm_ingestion_guard.neutralize import neutralize
from llm_ingestion_guard.sanitize import sanitize
# Every transform surface, called through a uniform (text, max) shim so the
# invariant is asserted once per surface rather than restated three times.
TRANSFORMS = (
("sanitize", lambda text, cap: sanitize(text, max_input_chars=cap)),
("fence", lambda text, cap: fence(text, max_input_chars=cap)),
("neutralize", lambda text, cap: neutralize(text, max_input_chars=cap)),
)
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_oversize_input_is_rejected(name, call):
with pytest.raises(OversizeInputError):
call("a" * 101, 100)
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_input_exactly_at_the_cap_is_accepted(name, call):
# The cap is the largest accepted size, not the smallest rejected one.
result = call("a" * 100, 100)
assert result.text is not None
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_rejection_is_a_contract_violation(name, call):
# Subclass, so a pipeline with a broad `except ContractViolation` around its
# quarantined stage keeps failing closed instead of meeting a new exception
# type it has never heard of.
with pytest.raises(ContractViolation) as exc:
call("a" * 101, 100)
assert exc.value.code == "oversize-input"
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_error_carries_sizes_never_content(name, call):
# Same alert-safety property ContractViolation already promises: the raised
# object must be routable to an alert channel without leaking the payload.
secret = "CANARYVALUE"
with pytest.raises(OversizeInputError) as exc:
call(secret * 100, 100)
assert secret not in str(exc.value)
assert not any(secret in d for d in exc.value.details)
assert "101" not in exc.value.details # details name the surface, not sizes
assert name in exc.value.details
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_error_reports_both_the_size_and_the_cap(name, call):
with pytest.raises(OversizeInputError) as exc:
call("a" * 101, 100)
message = str(exc.value)
assert "101" in message and "100" in message
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
def test_under_cap_is_unchanged_by_the_guard(name, call):
# The cap must not perturb the ordinary path: clean prose still round-trips
# the way each surface's own tests already pin.
result = call("ordinary prose", 100)
assert "ordinary prose" in result.text
@pytest.mark.parametrize(
"func", [sanitize, fence, neutralize], ids=["sanitize", "fence", "neutralize"]
)
def test_default_cap_comes_from_calibration(func):
# Calibration numbers live in ONE module; a literal re-typed at a call site
# is the drift this pins against.
default = inspect.signature(func).parameters["max_input_chars"].default
assert default == MAX_INPUT_CHARS
def test_prepare_input_inherits_the_cap():
# The input path is sanitize -> fence, and step 1 is where the document
# arrives, so the whole path is bounded by sanitize's guard alone.
with pytest.raises(OversizeInputError):
prepare_input("a" * (MAX_INPUT_CHARS + 1))
def test_prepare_input_accepts_a_document_at_the_cap():
result = prepare_input("a" * MAX_INPUT_CHARS)
assert result.fenced