Repository navigation
fix: limit the offsets @validateByteRange and @rxGlobal add to the rule reference - #3756
Conversation
…le reference @validateByteRange appended one "o<offset>,1" entry to the rule message reference for every byte outside the allowed range, and @rxglobal one entry for every capture. Applied to a large value such as REQUEST_BODY, the reference grows to about ten times the size of the inspected value. It is kept in Transaction::m_rulesMessages for the whole transaction and written in full to a JSON audit record (the error log and the Native audit log already cut it to 200 characters). Add Operator::MAX_LOGGED_OFFSETS (20) next to logOffset() and stop logging offsets past it in the two operators whose number of matches depends on the input. The match result does not change: @validateByteRange still counts every out-of-range byte, and @rxglobal still stores every capture. The other logOffset() callers log a single offset per evaluation, or one per capture group of the rule's own pattern. The reference is not part of the portable logging contract (seclang-spec ADR-0021: only [id], [msg] and [data] are normative, the JSON audit shape is unspecified), so limiting it changes no specified behaviour. Add a regression case to operator-validate-byte-range.json and to operator-rxGlobal.json with 40 matches each, expecting exactly 20 offsets in the reference.
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are reported for the change that limits logged match offsets.
No open issues 🎉
What changed in this PR
This PR bounds match-offset references for @validateByteRange and @rxGlobal, reducing reference growth for large inputs without changing matching results or capture storage.
Changes:
- Adds a shared limit of 20 logged offsets per operator evaluation.
- Applies the limit while preserving full invalid-byte counting and capture processing.
- Adds regression cases that verify 40 matches produce exactly 20 logged offsets.
| File | Description |
|---|---|
test/test-cases/regression/operator-validate-byte-range.json |
Tests the offset limit with 40 out-of-range bytes. |
test/test-cases/regression/operator-rxGlobal.json |
Tests the offset limit with 40 regex matches. |
src/operators/validate_byte_range.cc |
Caps logged offsets while counting all invalid bytes. |
src/operators/rx_global.cc |
Logs offsets for only the first 20 captures. |
src/operators/operator.h |
Defines and documents the shared offset limit. |
Review effort: Balanced
fzipi
left a comment
There was a problem hiding this comment.
Thanks, the problem is real and the tests are clear. I think the cap is in the wrong place, though, so the reference can still grow with the input.
1. The cap is per operator call, but m_reference is per rule.
RuleWithOperator::evaluate() passes the same RuleMessage to every target variable (and to chained rules), and appends v<offset>,<len> for each match (src/rule_with_operator.cc, around line 298-304). For a non-multiMatch rule the reference is only cleared after the final performLogging(). So with a rule like CRS 920270 (@validateByteRange on REQUEST_HEADERS|ARGS|ARGS_NAMES) and 1000 args, each holding 20 out-of-range bytes, we still build roughly 21,000 entries (~200 KB) that end up in m_rulesMessages and in the JSON audit reference. The new tests use a single target and only check the error log, which is already cut to 200 characters, so they can't catch this.
2. Suggestion: enforce the limit once, in logOffset().
Something like a size check on the reference itself:
static void logOffset(RuleMessage &ruleMessage, int offset, int len) {
if (ruleMessage.m_reference.size() >= MAX_REFERENCE_SIZE) {
return;
}
...
}with the same guard on the v.../transformation appends in rule_with_operator.cc. That covers all logOffset() callers, the build-up across variables and chained rules, and any future operator, without special cases in rx_global.cc and validate_byte_range.cc. It also fixes two smaller issues with the current version:
@rxGlobalcounts captures, not matches, so with a grouped pattern like(a)(b)the cut lands in the middle of the 7th match.- The comment says 20 entries fit the 200-character error-log limit, but that only holds for small offsets. At body offsets around 1,000,000, 20 entries are already 200 characters and the
v...entry naming the matched variable is cut off.
3. @rxGlobal memory is still unbounded.
Only the logged string is capped. searchGlobal() still collects every match, and with capture each one also goes into TX and m_matched. @rxGlobal . on a 1 MB body still produces about a million entries. That's probably a separate PR, but the description shouldn't suggest the growth is fixed.
Minor:
- In
validate_byte_range.cconlycount != 0is used, so the loop couldbreakat the cap instead of scanning the rest of a large body. - In
rx_global.cc,if (captures.size() > 0) return true; return false;is justreturn !captures.empty();. - Not caused by this PR: offsets are appended before negation is applied, so with
!@validateByteRangethe offsets from a variable that did not match stay in the reference and get attached to the next variable that does match (e.g.a=%01%01&b=okgiveso0,1o1,1v..). A reference-level cleanup might be a good moment to fix that too.
The previous change limited the offsets per operator call, but the reference belongs to the rule: RuleWithOperator::evaluate() passes the same RuleMessage to every target variable and chained rule, and appends the variable origin and the transformation path for every match. A rule like CRS 920270 (@validateByteRange on REQUEST_HEADERS|ARGS|ARGS_NAMES) on a request with many arguments still built a reference that grows with the input. Replace Operator::MAX_LOGGED_OFFSETS with a limit on the size of the reference itself (Operator::MAX_REFERENCE_SIZE, 1024 bytes), enforced in one place, Operator::appendReference(). logOffset() and the origin and transformation entries in RuleWithOperator::evaluate() go through it, so every operator and the build-up across variables and chained rules are covered. - @rxglobal stops logging offsets once the reference is full instead of after 20 captures, so a grouped pattern is no longer cut in the middle of a match. It still collects and captures every match. - @validateByteRange only uses count != 0, so it stops scanning once the reference is full. The regression cases now check the "reference" field of the JSON audit record, which is not truncated, for a single large value (both operators) and for 60 target variables.
Operators log their offsets while they evaluate, before the negation is applied. With a negated operator, the offsets of a value that the operator found something in, and that therefore did not match, stayed in the rule message reference and were attached to the next value that did match. For example, SecRule ARGS "!@validateByteRange 32-126" with a=%01%01&b=ok logged [ref "o0,1o1,1v<offset>,2"] for b. Operator::evaluateInternal() now restores the reference to its previous size when the final result is not a match. Add a regression case for the example above.
|
Thanks, all good points. I've pushed two more commits. 1–2. The limit is now on the reference, in one place. The tests now check the When the reference fills up with offsets, the 3. Minor:
|
|
fzipi
left a comment
There was a problem hiding this comment.
Thanks, this addresses everything from my first pass.
- The bound is now on the reference itself, and every append goes through
appendReference():logOffset()and the origin and transformation entries inRuleWithOperator::evaluate(). So the build-up across targets and chained rules is covered. The multi-variable test (60 args × 20 bytes) checks exactly that case, against the untruncated JSONreference. - The early
breakin@validateByteRangeis safe, sincecountis already non-zero when it fires. It also stops at the first bad byte when an earlier variable has already filled the reference. - Restoring the reference size in
evaluateInternal()when the final result is not a match covers both negated operators and any operator that logs before returning false. Since every rule goes throughexecuteOperatorAt(), it's the right single place for it.
On your question about the v... entry: dropping it once a kilobyte of offsets has been logged is fine with me. The variable is still identified by the message and MATCHED_VAR_NAME. Reserving room for it would bring back per-variable growth unless it had a second bound, which isn't worth it for a highlighting aid.
One nit, not blocking: appendReference() is really a RuleMessage concern, but moving it there would touch the public header, so keeping it on Operator is a reasonable trade-off.
LGTM.



what
Limit the number of match offsets that
@validateByteRangeand@rxGlobaladd to the rule message reference to 20 per evaluation.why
@validateByteRangeappends oneo<offset>,1entry toRuleMessage::m_referencefor every byte outside the allowed range, and@rxGlobalappends one entry for every capture. Neither has an upper limit. For a rule likeand
GET /?p=followed by 40 times%01, the error log line ends inThat is 40 entries for 40 input bytes. When the operator is applied to a large value such as
REQUEST_BODY(CRS 920272 and 920273 do this at paranoia levels 3 and 4), each entry takes up to 11 characters, so the reference grows to about ten times the size of the inspected body. Nothing reads the offsets back, and the operator's result only depends on whether there was at least one match. The string is still:Transaction::m_rulesMessagesfor the whole transaction,src/transaction.cc,LOGFY_ADD("reference", ...)).The error log and the Native audit log already cut the reference to 200 characters (
RuleMessage::_details()), but only after the full string has been built and copied.how
Operator::MAX_LOGGED_OFFSETS(20) next tologOffset()insrc/operators/operator.h. Twenty entries fit in about 220 characters, which matches the 200-character limit the text logs already apply.@validateByteRangestops logging offsets after the 20th, but still counts every out-of-range byte.@rxGloballogs the offsets of the first 20 captures only. It still stores every capture inTXwhencaptureis set.The match result of both operators does not change. The other
logOffset()callers log a single offset per evaluation, or one per capture group of the rule's own pattern, so they are not changed. No public header underheaders/modsecurity/is touched.The reference is not part of the portable logging contract (seclang-spec ADR-0021: only
[id],[msg]and[data]are normative, and the JSON audit layout is unspecified). The only visible change is that a JSON audit record lists at most 20 offsets for these two operators. The text logs look the same as before.tests
Two new regression cases, both in files that are already listed in
test/test-suite.in:operator-validate-byte-range.json: 40 out-of-range bytes, expects exactly 20 offsets followed by the variable entry.operator-rxGlobal.json: 40 matches, same expectation.Both cases fail without the change and pass with it.
make checkpasses (5144 passed, 2 skipped),make check-stylereports nothing new for the touched files, and the seclang-spec libmodsecurity conformance adapter shows no new failures.AI disclosure
Made with help of Claude Code, Opus 5.5
Summary by CodeRabbit