Compare commits

...

2 Commits

Author SHA1 Message Date
Xavier Roche
6cbdd58260 filterbounds: pin the class-branch shape against a long subject
The existing lower-bound pin uses `*a`-style literal segments. Add a
`*[A-Z,a-z,0-9]`-class pattern (the shape real filters use) against a
512-byte subject and assert it still matches well under the work cap, so a
future tightening of STRJOKER_MAXSTEPS that would break real class filters
fails here. This is the invariant the depth-cap budget reorder is closest
to threatening; the timeout regression itself is guarded by the fuzzer
corpus seed under -timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-15 20:14:38 +02:00
Xavier Roche
031d4a498f Count strjoker's depth-capped calls against the work budget
strjoker_rec charged its work budget (nsteps) only after the depth-cap
early-return, so a branch bottoming out at STRJOKER_MAXDEPTH cost nothing.
A pattern like *[a]*[a]... drives the recursion to the cap, which sets the
sticky `cut` flag and disables the failure memo for the rest of the match;
with the memo gone and deep calls uncounted, the search just below the cap
re-explores exponentially and never trips the budget. A ~2KB filter then
runs for tens of seconds under ASan (OSS-Fuzz 535114376: timeout in
fuzz-filters).

Move the increment ahead of the depth-cap return so every call counts; the
budget now bounds total work and the match fails safe. Worst-case fuzz
input drops from ~80s to ~0.3s. Real filters are unaffected: 3M random
differential cases agree with the prior matcher, and the earlier abort only
touches patterns already past any real filter's recursion depth.

Regression seed regress-classdepth-timeout.bin trips -timeout=25 in the CI
corpus replay on master and replays instantly with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-15 19:58:31 +02:00
4 changed files with 19 additions and 2 deletions

View File

@@ -39,6 +39,7 @@ EXTRA_DIST = README.md run-fuzzers.sh \
corpus/filters/filter.bin corpus/filters/filter-size.bin \
corpus/filters/regress-empty-subject-unique.bin \
corpus/filters/redos-star-classes.bin \
corpus/filters/regress-classdepth-timeout.bin \
corpus/url/http-url.txt corpus/url/relative-path.txt \
corpus/url/regress-file-empty-path.txt corpus/url/regress-long-path-abort.txt \
corpus/header/full-response.txt corpus/header/redirect.txt \

Binary file not shown.

View File

@@ -154,12 +154,14 @@ static const char *strjoker_rec(strjoker_memo *memo, const char *chaine,
if (depth > memo->maxdepth)
memo->maxdepth = depth;
/* Charge the budget before the depth cap: cut disables the memo, so uncounted
deep calls would let the sub-cap search explode (OSS-Fuzz 535114376). */
if (memo->nsteps != NULL && ++*memo->nsteps > STRJOKER_MAXSTEPS)
return NULL; /* work budget spent: fail the match safely */
if (depth >= STRJOKER_MAXDEPTH) {
memo->cut = HTS_TRUE;
return NULL; /* nesting beyond any real filter: fail the branch safely */
}
if (memo->nsteps != NULL && ++*memo->nsteps > STRJOKER_MAXSTEPS)
return NULL; /* work budget spent: fail the match safely */
if (memo->failed) {
bit = (size_t) (chaine - memo->chaine0) * memo->stride +
(size_t) (joker - memo->joker0);

View File

@@ -794,6 +794,20 @@ static int st_filterbounds(httrackp *opt, int argc, char **argv) {
memset(subj, 'a', 32);
subj[32] = '\0';
assertf(strjoker(subj, pat, NULL, NULL) != NULL);
/* Same pin for the class-branch shape users actually write (*[..]), against a
long subject: it must match with room to spare under the work cap. */
{
const char *seg = "*[A-Z,a-z,0-9]";
const size_t seglen = strlen(seg), nseg = 16;
for (i = 0; i < (int) nseg; i++)
memcpy(pat + i * seglen, seg, seglen);
pat[nseg * seglen] = '\0';
memset(subj, 'a', 512);
subj[512] = '\0';
assertf(strjoker_bounds(subj, pat, &steps, &maxsteps, NULL, NULL) != NULL);
assertf(steps < maxsteps);
}
freet(pat);
freet(subj);
printf("filterbounds: OK\n");