Skip to content

Reject unparseable regexp patterns instead of panicking - #61

Merged
korya merged 6 commits into
masterfrom
korya-fix-regex-panic
Aug 7, 2026
Merged

korya merged 6 commits into
masterfrom
korya-fix-regex-panic

Conversation

@korya

@korya korya commented Aug 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

A typo in an assertion pattern crashed the tool with a Go stack trace.

$ http-assert --assert-body '[unclosed' https://api.example.com
panic: regexp: Compile(`[unclosed`): error parsing regexp: missing closing ]: `[unclosed`
goroutine 1 [running]: ...
[exit=2]

Three flags carry user-supplied regexps — --assert-body, --assert-header, --assert-redirect — and all three compiled them with regexp.MustCompile. Assertion patterns need heavy shell escaping, so typos are routine; in CI this surfaced as a raw crash with an exit code that appears nowhere in the documented contract.

The RCA found these were the only user input in the program with no validation path. --log-level, --maphost, the environment variables, the method and the URL each already report bad values through die(). These three had nowhere to report to, because the constructors returned an Assertion and nothing else.

Present since the first commit (0faf21c, 2021-12-04); regexp.Compile had never appeared in this repo. A design gap, not a regression.

Solution

Give the three constructors error returns, then make the whole class of crash unexpressible.

Before / after:

$ http-assert --assert-body '[unclosed' https://api.example.com
Error: Invalid value for --assert-body flag: error parsing regexp: missing closing ]: `[unclosed`
$ echo $?
71

Exit 71 matches --log-level and --maphost, not 103 — that is the usage code, and this is a bad value, not bad usage. (A PreRunE error would have produced 103; that is why the fix uses die(71) directly.)

Fixing one flag proves nothing about the other eighteen, so three guardrails follow, at three different layers:

Layer Guardrail Effect
Static forbidigo rejects regexp.MustCompile the bug cannot be written
Parser 5 fuzz targets invariants proven, not read
CLI 540 hostile invocations no flag can crash the binary

The panic probe reads its own flag list

TestE2ENoFlagPanics parses --help at run time rather than hard-coding 19 flags, so a flag added later is probed without anyone remembering. It asserts it found at least 19, so a --help format change fails loudly instead of quietly probing nothing.

Twenty inputs per flag, chosen to break parsers rather than be rejected politely: unterminated classes and groups, trailing escapes, inverted repetition bounds, format specifiers, flag-lookalikes, control whitespace, multi-byte text, a 10,000-character string, 500 unbalanced brackets. Driven through every flag, the six environment variables, and the positional URL.

Only a crash fails it — any exit code the CLI chooses deliberately is correct behaviour.

Fuzzing found a second panic

Written to prove the parsers were safe, the targets failed on their sixth seed:

printPayload(w, []byte("x"), -1)  ->  panic: runtime error: slice bounds out of range [:-1]

Not reachable today — the sole caller passes the constant 256 — so it is latent, and it survived a static review that correctly identified MustCompile as the only reachable panic and wrongly concluded it was the only panic. Now guarded; the function is total.

Each target then ran 20s (~5.2M executions, ~26M total) with no further findings.

Verified in both directions

Neither guardrail is taken on trust:

  • Injecting a panic into parseHeaderLine makes the probe report --assert-header = "[unclosed" crashed the process (exit 2).
  • Reverting assertions.go to MustCompile makes just pre-commit fail.

An earlier injection into AssertBodyEmpty was not caught — that is how the recorded limitation was found: pflag rejects a non-boolean before it reaches this program, so for boolean flags the probe mostly exercises the flag layer. Documented in the test rather than papered over.

Coverage stays at 100.0%. No visual change — headless CLI.

Other Changes

  • .golangci.yml introduced (the repo had none). It adds only forbidigo; the standard set stays enabled, verified by making an unused symbol and the new rule fire together.
  • The default linter set is now asserted, not just documented. .golangci.yml adds to the standard set rather than replacing it — confirmed against the docs (linters.default defaults to standard; enable is additive; exclusions.presets defaults to []) and by diffing the enabled linters with and without the config, where the only difference is +forbidigo. But linters.default: none would silently drop all five standard linters and look like success, since fewer linters means fewer findings. A lint-config-check recipe now fails if any expected linter is missing. Its two failure modes report differently on purpose: an unreadable output blames the parser, a missing linter names the linter.
  • The rule covers test files too. The one legitimate use — a literal pattern in the probe's --help parser — was rewritten to compile-and-report rather than carrying a suppression, since an exclusion would apply to every future test.
  • TestKnownIssue17RegexPanic asserted the panic and now asserts the rejection. That flip is the record of the fix.

Not fixed: there is still no uniform validation point, so a future flag with a fallible value depends on its author noticing. forbidigo narrows that to "not via MustCompile"; closing it properly needs the error-flow restructuring in #55.

Related:

🤖 Generated with Claude Code

korya and others added 5 commits August 7, 2026 18:02
A typo in a pattern crashed the tool with a Go stack trace and exit code 2, a code that appears
nowhere in the documented contract and told the user nothing about which flag was at fault:

    $ http-assert --assert-body '[unclosed' https://example.com
    panic: regexp: Compile(`[unclosed`): error parsing regexp: missing closing ]: `[unclosed`
    goroutine 1 [running]: ...
    [exit=2]

Three flags carry user-supplied regexps -- --assert-body, --assert-header and --assert-redirect --
and all three compiled them with regexp.MustCompile. They were also the only user input in the
program with no validation path: --log-level, --maphost, the environment variables, the method and
the URL each already report bad values through die(). These three had nowhere to report to,
because the constructors returned an Assertion and nothing else.

Now they return (Assertion, error), and mustCompileAssertion reports failure the same way every
other invalid flag value is reported:

    Error: Invalid value for --assert-body flag: error parsing regexp: missing closing ]: `[unclosed`
    [exit=71]

Exit 71 matches --log-level and --maphost rather than 103, which is what a cobra PreRunE error
would have produced -- 103 is the usage code, and this is a bad value, not bad usage.

The failure predates every other commit: it has been present since 0faf21c (2021-12-04), and
regexp.Compile has never appeared in this repo. It is a design gap rather than a regression.

TestKnownIssue17RegexPanic asserted the panic and now asserts the rejection, renamed accordingly;
that flip is the record of the change. Coverage stays at 100.0%.

This does not add a uniform validation point for flag values -- a future flag with a fallible value
still depends on its author remembering. Narrowing that further needs the error-flow restructuring
in #55.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
#17 was reachable because nothing checked that user input could not crash the process. One flag was
found and fixed; nothing established that the other eighteen were safe.

Probes every flag with twenty inputs chosen to break parsers rather than to be rejected politely:
unterminated character classes and groups, trailing escapes, inverted repetition bounds, format
specifiers, traversal-shaped paths, flag-lookalikes, control whitespace, multi-byte text, a
10,000-character string, and 500 unbalanced brackets. The same corpus is driven through the six
environment variables and the positional URL, which are the other two ways a value reaches a parser.

The flag list is read from --help at run time rather than hard-coded, so a flag added later is
probed without anyone remembering to add it. A fixed list would silently stop covering the CLI the
moment it grew. The parser asserts it found at least nineteen flags, so a --help format change
fails loudly instead of quietly probing nothing.

Only a crash fails the test. Any exit code the CLI chooses deliberately is acceptable -- rejecting
bad input is correct behaviour, and pinning specific codes here would duplicate the exit-code test
and break every time a message changed.

Verified by injecting a panic into parseHeaderLine, a path the probe reaches: the suite reports
`--assert-header = "[unclosed" crashed the process (exit 2)`. An earlier injection into
AssertBodyEmpty was not caught, which is how the recorded limitation was found -- pflag rejects a
non-boolean before it reaches this program, so for boolean flags the probe mostly exercises the
flag layer, and their own paths stay covered by the config matrix.

    540 hostile invocations: 20 flags x 20 values, plus 6 environment variables and the URL

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
printPayload sliced its input to maxSize without checking the sign, so a negative limit produced
`slice bounds out of range [:-1]` and crashed the process.

    printPayload(w, []byte("x"), -1)  ->  panic: runtime error: slice bounds out of range [:-1]

Not reachable today: the sole caller passes the constant 256 (main.go:466). It is a latent defect
rather than a live one, which is why it survived a static review that correctly identified
regexp.MustCompile as the only *reachable* panic and wrongly concluded it was the only panic.

A negative limit is now treated as zero, cropping the whole payload -- the same result as passing 0,
which the existing table already covers. The function becomes total: no input can make it panic.

Found by a fuzz target written to prove the parsers were panic-free, on its sixth seed, before the
fuzzer had generated a single input of its own. The targets follow in the next commit; the fix
lands first so that every commit builds green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
Every parser in this package slices strings at offsets derived from strings.Index. Reading them
suggests each index is guarded; nothing proved it, and a static review that reached that conclusion
had already missed the negative-crop panic fixed in the previous commit.

Adds five targets covering parseHeaderLine, parseHostMappings, hostMapping.Matches and DstHost,
printPayload and parseLogLevel. Each asserts an invariant beyond "did not crash", so the targets
catch wrong answers as well as panics: a parsed header value never appears without a name, a failed
mapping parse never returns mappings alongside its error, a destination that already carries a port
comes back unchanged, and a cropped byte count never exceeds the payload.

Seed corpora run as ordinary subtests under `go test`, so these guard CI at no cost -- 35 seed
cases, no fuzzing engine required. Searching for new inputs is opt-in:

    go test -run '^$' -fuzz FuzzParseHostMappings -fuzztime 60s

Each target was run for 20 seconds during development, about 5.2 million executions apiece and 26
million in total, with no failures beyond the one already fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
Adds the guardrail that makes #17 unexpressible rather than merely fixed. Every regexp pattern in
this program arrives as a flag value, so MustCompile turns a user typo into a crash; the linter now
rejects it and points at the alternative.

    assertions.go:85:8: use of `regexp.MustCompile` forbidden because "use regexp.Compile and
    report the error; MustCompile panics on user input (#17)" (forbidigo)

The repo had no golangci-lint configuration, so this introduces one. It only adds forbidigo -- the
standard set (errcheck, govet, ineffassign, staticcheck, unused) stays enabled, verified by
reintroducing an unused symbol alongside the new rule and watching both fire.

The rule deliberately covers test files too. The one legitimate use, a literal pattern parsing
--help output in the panic probe, was rewritten to compile and report rather than carrying a
suppression comment: an exclusion would have to be justified once and would then apply to every
future test.

Verified in both directions -- reverting assertions.go to MustCompile makes `just pre-commit` fail,
and the current tree passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
@korya
korya marked this pull request as ready for review August 7, 2026 22:14
.golangci.yml is meant to add to the default linter set, never replace it, and a comment saying so
is not a guarantee. Setting `linters.default: none` would silently drop errcheck, govet,
ineffassign, staticcheck and unused, and nothing would fail -- fewer linters means fewer findings,
which looks exactly like success.

Adds a lint-config-check recipe that reads the enabled set from `golangci-lint linters` and fails
if any expected linter is missing, wired into pre-commit so local runs and CI check it through the
same command.

The two failure modes report differently on purpose. An unreadable output means the parser is stale
and points at this recipe; a missing linter names the linter and points at linters.default. The
first draft conflated them by treating "fewer than five" as a parse failure, which sent the reader
to the wrong file for the more likely of the two problems.

Verified in both directions: adding `default: none` reports the missing linter, a golangci-lint
stub with unrecognised output reports the stale parser, and the current tree passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
@korya korya mentioned this pull request Aug 7, 2026
@korya
korya merged commit 2f9afd5 into master Aug 7, 2026
1 check passed
@korya
korya deleted the korya-fix-regex-panic branch August 7, 2026 22:19
korya added a commit that referenced this pull request Aug 8, 2026
The exit code is this tool's entire output, and --help never mentioned one.
Nor did it reveal that six options can be set without touching the command
line, or that the transport already honours HTTP_PROXY, HTTPS_PROXY and
NO_PROXY -- so users behind a corporate proxy went looking for a flag that
does not exist, and users with the variable set for unrelated reasons had
their health checks silently rerouted with nothing to explain it.

The README documents the first two. It is not what anybody reaches for with
a terminal already open.

Exit code 2 is deliberately absent. It was the Go panic path; #61 removed it
and the hostile-input probe keeps it removed, so listing it would document
something that can no longer happen.

The new test pins all of this against the harness's own exit-code constants
rather than against literals, so the prose and the contract cannot drift
apart quietly. It also guards a trap this change created: the flag-panic
probe locates the flag list by cutting the help output at the first
"Flags:", and that string appearing in the prose above would silently point
it at the wrong section -- a probe that reads no flags is a probe that
passes. Both failure modes were confirmed by mutation before committing.

Closes #24
Closes #39
Closes #46

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
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.

Panic: invalid regex in --assert-body/--assert-header/--assert-redirect crashes with a stack trace

1 participant