An egress filter scans outgoing JSON for identifying filesystem-path-like strings. Its detector is a regex authored against ordinary text but applied to the serialized document, where the platform separator is escaped, so the wire form differs from the logical string and the rule silently misses.
Hypothesis: a layering error, not a regex defect. Patching the pattern to expect a doubled separator is brittle, since an encoder may instead emit a unicode escape for the same code point, and lookalike characters fold only under compatibility normalization. Decoding, normalization and confusable detection are three distinct layers. Validate once at the decoded value, and prefer allowlisted per-field shapes over denylisted substrings.
Open question: on rejection the value must not reach the log, since the log is what the filter protects. Which residual signals keep a false positive diagnosable?
Changed question. The filter is now also an availability surface. Three new inputs to survive: very deep nesting, very large documents, and text that drives catastrophic backtracking in the detector.
Design conclusions, reasoned rather than measured.
One. Budget each resource at the layer that spends it, and enforce incrementally with early abort rather than checking after the work is done. Separate caps on raw input bytes before parsing, container depth, total node count, keys per object, decoded length of each string, numeric literal length, matcher steps, and one wall clock deadline as a last resort. Node count matters independently of bytes, because a small document can still expand into very many nodes.
Two. Counted budgets should be the real gate and wall clock only a backstop. Counted units are reproducible, so verdicts and tests stay deterministic; a design gated on elapsed time produces machine dependent and load dependent results.
Three. Compatibility normalization can expand a string, so a length cap measured before normalization does not bound the cost after it. One known worst case is a single Arabic ligature code point expanding to roughly eighteen characters. Cap the normalized length as well, or cap the expansion ratio.
Four. The earlier allowlist decision also removes the backtracking class, which was an unexpected convergence. Reducing detection to character class scans and narrow per-field shapes means a linear time matcher suffices, and a matcher with no backtracking has no catastrophic case to budget. A step limit is then only defense in depth for engines that still backtrack; on an engine offering no step limit, the remaining options are a watchdog in a separate thread or process, which is itself an argument for changing engine.
Five. Remove unbounded recursion from the filter entirely, using an explicit heap worklist with a depth counter. A depth cap must produce a clean rejection, because native stack exhaustion is fatal or barely recoverable depending on runtime, and a filter that dies may fail open if the caller error path skips it.
Six. The critical rule for failing closed: when any budget aborts, withhold the whole document, never the portion that was successfully scanned. Partial scanning is precisely the attacker goal, since burying an identifying value past the abort point converts a denial of service into a disclosure. Structure the filter so deny is the initial value and the only assignment to allow is the final statement after successful completion, making every early exit a deny. Sharing fails closed while the primary task continues, because the verdict gates only the optional sharing path.
Seven. Warning output needs its own budget. Emit a closed enumerated set of category counters plus which budget fired first, one aggregate record per pass rather than one per offending field, and cap distinct categories with a single truncation flag beyond that. Unbounded warning volume is itself a denial of service against the log pipeline and the on call responder. Since no input bytes enter the message, log injection through control characters disappears as a side benefit. Translate engine level errors into category codes, because some matcher limit errors include a fragment of the subject text.
Eight. Derive the numbers from the legitimate schema rather than from guesses about the attack: take a high percentile of real payload shape per dimension and allow a small multiple. Where the envelope is fixed, depth is statically known and the cap should be near that, not a large round number. Keep budgets configurable under a compiled ceiling that configuration cannot raise.
Highest value test identified, not yet executed. A property test that plants a random marker token in a synthetic payload, forces each budget to abort, and asserts the marker appears nowhere in any emitted warning or metric. It checks the never echo invariant directly instead of checking the wording of messages.
Unknowns. Good default for the expansion ratio cap. Whether a separate process for the matcher is worth its cost when the engine cannot be replaced. Whether reporting which budget fired first leaks useful structural information about the payload to an attacker probing the filter.