Skip to content

fix(detection): a constant host with a tainted query string is not Go SSRF - #97

Merged
bkd-dotcom merged 1 commit into
mainfrom
fix/go-ssrf-constant-host
Aug 18, 2026
Merged

fix(detection): a constant host with a tainted query string is not Go SSRF#97
bkd-dotcom merged 1 commit into
mainfrom
fix/go-ssrf-constant-host

Conversation

@bkd-dotcom

Copy link
Copy Markdown
Member

Follow-up to #95, found by the eval corpus rather than by inspection.

The false positive

go.taint.ssrf fired whenever a tainted identifier appeared on the request line, so this was reported as SSRF:

q := r.URL.Query().Get("q")
resp, _ := http.Get("https://api.example.com/search?q=" + url.QueryEscape(q))

The destination host is compiled in — only the query string is user-controlled. That is not SSRF.

It is also exactly the false-positive class I held @AdvaitVarhade's Python SSRF PR to in #89 (constant URL with tainted params/json/headers). The Go rule should meet the same bar.

How it surfaced: while adding the eval corpus cases for Signetry/eval#11/#12/#29, the LANG-60-SAFE-go-constant-url decoy scored recall 1.0 but 1 false positive. The decoy was right and my rule was wrong.

The fix

Adds an optional skip_if negative guard to SinkSpec — for sinks where a tainted identifier on the line does not imply taint in the dangerous position. This mirrors the _PARAM_PLACEHOLDER special case the SQL sinks already have, but generically.

On go.taint.ssrf:

skip_if=re.compile(r'"https?://[A-Za-z0-9.-]+[/"]')

The [A-Za-z0-9.-]+ is load-bearing. Requiring at least one host character before the closing quote or path is what keeps the real attack firing:

line literal host pinned? fires
http.Get(target) none no yes
http.Get("https://" + userHost) "https:// — no host chars no yes
http.Get("https://api.example.com/search?q=" + q) scheme + host + / yes no ✅
http.Get("https://api.example.com/items/" + id) scheme + host + / yes no ✅

A naive "https?://" guard would have silently broken the "https://" + userHost case — a real SSRF where the attacker controls the whole host.

Verification

278 tests pass, ruff clean. New test asserts both directions. With this, the eval corpus scores recall 1.0 at 0 false positives across all 60 cases (verified locally against this branch).

… SSRF

The go.taint.ssrf sink added in #95 fired whenever a tainted identifier
appeared on the request line, which flagged this as SSRF:

    q := r.URL.Query().Get("q")
    resp, _ := http.Get("https://api.example.com/search?q=" + url.QueryEscape(q))

The destination host is compiled in; only the query string is user
controlled. That is not SSRF — and it is the same false-positive class
the Python SSRF rule is held to (constant URL with tainted params), so
the Go rule should be held to it too.

Found by the eval corpus rather than by inspection: the LANG-60 safe
decoy in Signetry/eval scored 1 false positive while recall was 1.0.

Adds an optional `skip_if` negative guard to SinkSpec — for sinks where a
tainted identifier on the line does not imply taint in the dangerous
position — and sets it on go.taint.ssrf as:

    "https?://[A-Za-z0-9.-]+[/"]

Requiring at least one host character before the closing quote or path is
what keeps the real attack firing: `http.Get("https://" + userHost)` has
no host inside the literal, so the attacker still controls the
destination and it is still reported.

Verified both directions: tainted URL, `"https://" + host`, and a tainted
value through client.Do still fire; constant host with a tainted query,
a constant host with a tainted path suffix, and a fully constant URL do
not.
@github-actions

Copy link
Copy Markdown

Signetry Reviewer — 🟡 Needs human review

A human should decide — the required check is pending.

Deterministic gates (the authority)

Gate Status
Required status check ⏳ pending
Secret scan ✅ clean
CI permission / OIDC ✅ no forbidden change
Dependency skew ✅ ok
All green

Findings

No issues found by the deterministic scanners.

Merge

A human should review and merge.

This review is advisory. It never merges on its own judgement — the deterministic gates + a human are the authority. Findings can have false negatives; a green bot verdict is not a guarantee.

@bkd-dotcom
bkd-dotcom merged commit fceccb6 into main Aug 18, 2026
8 checks passed
@bkd-dotcom
bkd-dotcom deleted the fix/go-ssrf-constant-host branch August 18, 2026 19:59
bkd-dotcom added a commit to Signetry/eval that referenced this pull request Aug 18, 2026
…os (#31)

Closes #11, #12, #29, #13.

Corpus goes 52 -> 60 cases:
  * LANG-53/54  XXE (CWE-611) in Java and PHP            (#11)
  * LANG-56/57  path traversal (CWE-22) in Go and Java   (#12)
  * LANG-59     SSRF (CWE-918) in Go                     (#29)
  * LANG-55/58/60  three SAFE decoys

The decoys are the point as much as the positives: default-safe PHP XML
parsing (entities are off by default on PHP 8+, so "parses XML" is not the
bug), a constant filesystem path, and a constant host with a user-supplied
query string.

LANG-60 paid for itself immediately — it caught a false positive in
signetry-core's brand-new go.taint.ssrf rule (constant host + tainted
query string reported as SSRF), fixed in Signetry/core#97 before this
landed. That is exactly the false-positive class the Python SSRF rule is
held to, so the Go rule had to meet it too.

Real repos (#13): OWASP WebGoat (Java) and OWASP RailsGoat (Ruby), both
pinned to a full SHA. They are the first JVM/Ruby targets here — every
existing case is Python or JavaScript, which under-exercises the
multi-language tier.

Fixes the pin mechanism while adding them. scan_real_repo cloned with
--depth 1 then ran `git checkout <sha>` with check=False. On a shallow
clone the object is absent, so the checkout failed with

    fatal: unable to read tree <sha>

the failure was swallowed, and the scan silently ran against the
default-branch tip — so a case documented as "pinned for reproducibility"
was not pinned. Verified both behaviours against a real shallow clone.
Now fetches the object first and, if pinning truly cannot be honoured,
records that in the note instead of passing an unpinned scan off as
pinned.

Requires signetry-core v0.7.0 (pin bumped): the new cases depend on its Go
SSRF / Go+Java path-traversal / PHP XXE rules. Verified against the
published tag in a clean environment — 60 cases, recall 1.0, 0 false
positives; 50 tests and ruff green.

Co-authored-by: Binay <bkd-dotcom@users.noreply.github.com>
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.

1 participant