fix(http-honeypot): a semicolon hides the payload from four classifiers (#3364) - #3483
Merged
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
…asion under it The research document the issue asked for, and a root-cause parser fix that came out of writing it. The detector itself is not new: #3420 added the class, #3441 closed its case-folding gap, #3464 gave it a file. The issue's STATUS block ("implementation PRs are OPEN") is stale. docs/research/3364-roundcube-sqli.md documents the CVE as implemented, citing file:line, and carries forward the honest caveat from the source: coverage is NOT measured against the fleet corpus, because the honeypot-v2-* indices are not reachable from a branch. What is measured is #1888's pinned 30-day fixture, mirrored in roundcube_coverage_3364_test.go: 0 claims, 9/9 published shapes. No CVSS, build, or date is asserted beyond what the issue body and the class's own comment carry; no Kibana field names are invented. THE FIX. Four cases parsed parameters with url.ParseQuery behind if err != nil && len(values) == 0 { continue } Since Go 1.17 that parser rejects and drops any pair containing a semicolon, returning a map of the pairs that had none -- so the guard does not fire, parsing continues on a truncated map, and the attacker's parameter is simply absent: q="_user=x&;_action=login%27+OR+1%3D1--" err=invalid semicolon separator in query url.Values{"_user":[]string{"x"}} The targets are PHP, whose only separator is "&", so the payload reaches Roundcube in full while the sensor cannot see it. The guard's other silent case is the same bug: a value that will not decode makes ParseQuery return an empty map, the guard fires, and a deliberately broken escape (%zz) deletes the sensor's own evidence. The parser this needs already exists. classify_wordpress.go's formValues (#3449) splits on &, then the first =, unescapes each side, and keeps an undecodable side as-is rather than dropping it. All four call sites now use it -- classify_roundcube.go, classify_odata.go, classify_teamcity.go, laundering.go -- so this is one root cause retired rather than four bugs left. No new parser, no new abstraction, no new file; no deps, env vars, routes or ports. Surrounding logic and bounds are unchanged, and the 64 KiB body cap upstream of ServeHTTP still holds. BEHAVIOUR CHANGE, stated: a `;` in a value no longer makes the parameter invisible, and an undecodable value is no longer discarded. No false-positive surface is widened where it matters -- `;_action` is still not `_action`, `;$filter` is still not an OData system option, a semicolon with no payload behind it stays unlabelled, and qualifyingODataRequest's value-consistency gate is untouched and still runs on the value it is shown. One existing expectation changed: `$filter=Year%zz%2520eq` was unlabelled and is now odata-double-encode-probe. The old answer was url.ParseQuery deleting the pair on the %zz, not the gate declining -- the value carries a real %25, which is a % that decodes twice, and the target reads `Year%zz%20eq`. The rule is unchanged (a malformed escape is still not a residual escape, now pinned separately on a value whose only escape is broken); which requests reach the rule did change. Justified at the pin in odata_double_encode_test.go and in scanner_laundering_3430_test.go. classify_order_3464_test.go, the deliberate dispatch-order gate, passes unmodified and is not in this diff. PROOF. form_values_semicolon_3364_test.go fails on unmodified main (7 failing subtests, recorded before any source change) and passes after. It drives classifyPayload, qualifyingODataRequest and the real two-request laundering state, plus one ServeHTTP end-to-end for the event. Mutation-checked by watching each go red: dropping formUnescape's undecodable-side case (2 tests), re-introducing ';' as a separator in formValues (5 tests, including a pre-existing #3447 one), and reverting each of the three productive call sites individually. The teamcity site is pinned as gaining nothing, measured rather than assumed: its call name is a whole-string test, so the target's parser reaches the same verdict. Baseline -> final, same package: 129 top-level / 293 subtests -> 134 / 311, 0 fail, 0 skip, no new xfail, no weakened assertion. gofmt -l clean, go vet clean. tests/docs: 659 passed, 1 xfailed, 17 subtests, before and after.
Xore
force-pushed
the
oc/3364-roundcube-research
branch
from
September 29, 2026 09:14
34fcd7f to
1c47d70
Compare
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.
Closes #3364.
Two parts: the missing research document, and a root-cause parser fix the research exposed.
The bug
Since Go 1.17,
url.ParseQueryrejects every pair containing a;and drops it — but returns a non-nil map of the pairs that had none. Four call sites guarded with:The guard does not fire (there are values left), so parsing continues on the truncated map with the attacker's pair simply absent. The thing the guard was written to tolerate — a body that is not a parameter list at all — is exactly what makes the miss invisible: there is no error to notice.
This matters because the targets are PHP, whose only separator is
&. A;in a value is ordinary data that reaches the target intact while the sensor drops the parameter carrying it. Confirmed end-to-end in the red test: the request body reaches the event (_user=admin%5C%27;or+1%3D1--) but arrives unclassified.The fix
One root cause, four sites — all four swapped to the existing
formValueshelper (classify_wordpress.go, added by #3449 for exactly this reason). No new parser, no new file, no new abstraction:classify_roundcube.go,classify_odata.go,classify_teamcity.go,laundering.goEach site carries a comment explaining why
formValuesand noturl.ParseQuery, not just that the name changed.Proof
I re-ran the red check independently: restored all four call sites to
main'surl.ParseQueryversion, ran the new test, and got 7 failures, then restored. It is green on the branch.The author also mutation-checked it: dropping
formUnescape's undecodable-side case → 2 tests fail; re-introducing;as a separator → 5 fail, including a pre-existing #3447 one; reverting each productive call site individually → each caught.gofmt -lclean,go vetclean, full packageokwith-count=1(uncached).classify_order_3464_test.gopasses unmodified and is not in the diff.The one deliberate behaviour change — review this first
Three existing assertions moved, all on one fixture,
$filter=Year%zz%2520eq, and not because of the semicolon.url.ParseQueryreturns an empty map on that broken escape, so the guard fired and the pair was skipped.formValueskeeps the undecodable side, so the genuine%25— a%that decodes twice — is now visible, and the target readsYear%zz%20eq. The old empty classification was the parser deleting its own evidence, not the gate declining.The rule is unchanged: a malformed escape alone is still not a residual escape. That is now pinned separately on
$filter=Year%zz%2Gz, a value whose only escape is broken. What changed is which requests reach the rule. The old test was asserting the parser's behaviour rather than the sensor's rule; that distinction is now two rows instead of one blurred row, justified at the pin in both affected test files.This is a real change in what gets labelled. It is a widening — more requests qualify — and the reasoning is sound, but it is the thing to check if you disagree with the call.
Docs
docs/research/3364-roundcube-sqli.mdcarries the unmeasured-coverage caveat forward rather than papering over it: 0 claims against #1888's pinned fixture, 9/9 published shapes, explicitly labelled a fixture and not the fleet. No CVSS, build, or date asserted beyond the issue body and the class's own comment; AV26-503 is cited as second-hand because it was not re-fetched.Author and committer both
Xore <Xore@users.noreply.github.com>, no AI attribution trailers.