Repository navigation
Reject unparseable regexp patterns instead of panicking - #61
Merged
Merged
Conversation
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
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
Merged
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A typo in an assertion pattern crashed the tool with a Go stack trace.
Three flags carry user-supplied regexps —
--assert-body,--assert-header,--assert-redirect— and all three compiled them withregexp.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 throughdie(). These three had nowhere to report to, because the constructors returned anAssertionand nothing else.Present since the first commit (
0faf21c, 2021-12-04);regexp.Compilehad 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:
Exit 71 matches
--log-leveland--maphost, not 103 — that is the usage code, and this is a bad value, not bad usage. (APreRunEerror would have produced 103; that is why the fix usesdie(71)directly.)Fixing one flag proves nothing about the other eighteen, so three guardrails follow, at three different layers:
forbidigorejectsregexp.MustCompileThe panic probe reads its own flag list
TestE2ENoFlagPanicsparses--helpat 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--helpformat 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:
Not reachable today — the sole caller passes the constant
256— so it is latent, and it survived a static review that correctly identifiedMustCompileas 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:
parseHeaderLinemakes the probe report--assert-header = "[unclosed" crashed the process (exit 2).assertions.gotoMustCompilemakesjust pre-commitfail.An earlier injection into
AssertBodyEmptywas 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.ymlintroduced (the repo had none). It adds onlyforbidigo; the standard set stays enabled, verified by making an unused symbol and the new rule fire together..golangci.ymladds to the standard set rather than replacing it — confirmed against the docs (linters.defaultdefaults tostandard;enableis additive;exclusions.presetsdefaults to[]) and by diffing the enabled linters with and without the config, where the only difference is+forbidigo. Butlinters.default: nonewould silently drop all five standard linters and look like success, since fewer linters means fewer findings. Alint-config-checkrecipe 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.--helpparser — was rewritten to compile-and-report rather than carrying a suppression, since an exclusion would apply to every future test.TestKnownIssue17RegexPanicasserted 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.
forbidigonarrows that to "not viaMustCompile"; closing it properly needs the error-flow restructuring in #55.Related:
🤖 Generated with Claude Code