Skip to content

Send TCS polling secret in X-Tsunami-TCS-Secret header (CWE-598 client-side half)#163

Open
OffByQuant wants to merge 1 commit into
google:masterfrom
OffByQuant:security/tcs-secret-in-header
Open

Send TCS polling secret in X-Tsunami-TCS-Secret header (CWE-598 client-side half)#163
OffByQuant wants to merge 1 commit into
google:masterfrom
OffByQuant:security/tcs-secret-in-header

Conversation

@OffByQuant

Copy link
Copy Markdown

Summary

The Tsunami Callback Server (TCS) polling protocol used to carry the per-scan correlation secret in the polling URL query string (/?secret=<raw_secret>), which is logged by every standard intermediary on the path:

  • web-server access logs (the project documents nginx-fronted deployments as a supported topology)
  • cloud load balancer request logs (AWS ALB, GCP HTTP(S) LB log full URLs by default)
  • outbound forward proxies on the scanner side
  • browser history / Referer when a human opens the polling URL to debug a callback
  • ps-output of curl/wget invocations against the polling endpoint
  • CDN request-sampling analytics

None of these are appropriate channels for a secret. The raw secret was supposed to stay private to the scanner; only its SHA3-224 hash (the cbid) was the public projection. An attacker who reads the secret out of one of those log surfaces during the scan window can forge an out-of-band callback for a probe that never actually triggered, injecting a false-positive vulnerability finding into the operator's scan report (CWE-598 — Use of GET Request Method With Sensitive Query Strings).

This is the client-side half of the coordinated fix. The matching server-side PR is referenced below.

Changes

Two TCS client implementations now send the secret in an X-Tsunami-TCS-Secret request header instead of the URL query string. The polling URL becomes a clean <base>/ with no query string.

  • plugin/src/main/java/com/google/tsunami/plugin/TcsClient.java — adds the SECRET_HEADER_NAME constant; sendPollingRequest builds the request with the header.
  • plugin_server/py/plugin/tcs_client.py — adds the _SECRET_HEADER_NAME module-level constant; _build_polling_request attaches the header.

Header values are not logged by default in any of the channels listed above.

Coordination

Companion server-side PR (must merge and ship first):

That PR teaches InteractionPollingHandler to accept the secret via the new header, with a fallback to the legacy ?secret= query parameter so older Tsunami client releases keep polling correctly through the deprecation window. The fallback log is rate-limited to once per minute to surface upgrade guidance without log-flood.

Recommended merge order:

  1. Merge and release the server-side PR. From that release onward, the TCS server accepts both forms (header preferred, query as fallback).
  2. Merge this client-side PR. Operators upgrading the scanner after the server release: secure. Operators with the old scanner against a new TCS server: still leak via query, but no breakage; the deprecation log nudges them to upgrade. Operators with the new scanner against an old TCS server: polling fails, which is why the server PR has to land first.

Tests

  • TcsClientTest.isVulnerable_sendsValidPollingRequest updated: now asserts the recorded request path is "/" (no query string) AND that the request carries the secret in the X-Tsunami-TCS-Secret header.
  • tcs_client_test.test_has_oob_log_sends_polling_request asserts the same on the Python side using requests_mock.last_request (.qs == {}, header value matches).
  • The remaining has_oob_log_* Python tests are updated to register mock.register_uri('GET', '<URL>/', ...) (header-form URIs) instead of '<URL>/?secret=<SECRET>'.

Test plan

  • CI runs ./gradlew test against the patched Java sources. (Couldn't run gradle locally — no wrapper checked in, no system gradle; relying on this PR's CI.)
  • CI runs the Python plugin-server pytest suite against the patched tcs_client_test.py. (requests-mock + absl-py not in my local env; relying on CI.)
  • Manual sanity, after both PRs are merged: run an end-to-end scan with a callback-server-enabled detector and verify polling succeeds. Verify the polling request shows up in the TCS access log as GET / (no ?secret=...) and the X-Tsunami-TCS-Secret header is set.

References

  • CWE-598: Use of GET Request Method With Sensitive Query Strings
  • OWASP — Information Exposure Through Query Strings in URL
  • RFC 9110 §17.9: Sensitive Information in URIs

@google-cla

google-cla Bot commented Apr 29, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

The Tsunami Callback Server (TCS) polling protocol used to carry the
per-scan correlation secret in the polling URL query string
(`/?secret=<raw_secret>`). URL query strings are routinely captured
by:

  * web-server access logs (the project documents nginx-fronted
    deployments as a supported topology)
  * cloud load balancer request logs (AWS ALB, GCP HTTP(S) LB log
    full URLs by default)
  * outbound forward proxies on the scanner side
  * browser history / Referer when a human opens the polling URL
    to debug a callback
  * `ps`-output of curl/wget invocations against the polling
    endpoint
  * CDN request-sampling analytics

— none of which are appropriate channels for a secret. The raw
secret was supposed to stay private to the scanner; only its SHA3
hash (the cbid) was the public projection. Once the raw secret is
captured, a network-adjacent attacker can forge an out-of-band
callback for a probe that never actually triggered, injecting a
false-positive vulnerability finding into the operator's scan
report (CWE-598).

This is the **client-side half** of the coordinated fix.

Both the Java orchestrator's TcsClient and the Python plugin
server's tcs_client now send the secret in an `X-Tsunami-TCS-Secret`
request header. Header values are not logged by default in any of
the channels listed above. The polling URL becomes a clean
'<base>/' with no query string.

Coordinated with the matching server-side change in
google/tsunami-security-scanner-callback-server, which teaches the
polling handler to read the secret from the new header (preferring
header when present, falling back to the legacy query parameter
during a deprecation window). The server-side patch must merge
and ship before this client-side switch — otherwise new scanner +
old TCS server will fail polling because the old server only
knows about the query parameter.

Tests:

  * TcsClientTest.isVulnerable_sendsValidPollingRequest now asserts
    that the recorded request has path "/" with no query string and
    carries the secret in the X-Tsunami-TCS-Secret header.
  * tcs_client_test.test_has_oob_log_sends_polling_request asserts
    the same on the Python side using requests_mock's last_request
    inspection (qs is empty, header value matches).
  * Other has_oob_log tests are updated to register the new
    header-only URI.

References:

  * CWE-598: Use of GET Request Method With Sensitive Query
    Strings
  * RFC 9110 §17.9: Sensitive Information in URIs
@OffByQuant OffByQuant force-pushed the security/tcs-secret-in-header branch from c5c93d9 to 1ba6d37 Compare April 29, 2026 07:17
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