Skip to content

security: reject internal-network gbrain_url before consumer registration#1211

Open
garagon wants to merge 1 commit intogarrytan:mainfrom
garagon:security/brain-init-ssrf-validate-url
Open

security: reject internal-network gbrain_url before consumer registration#1211
garagon wants to merge 1 commit intogarrytan:mainfrom
garagon:security/brain-init-ssrf-validate-url

Conversation

@garagon
Copy link
Copy Markdown
Contributor

@garagon garagon commented Apr 25, 2026

Summary

gstack-brain-init POSTs the brain bearer token + the brain repo URL
to \${gbrain_url}/ingest-repo with no validation of where
\${gbrain_url} points. Anyone with the power to set the config —
write ~/.gstack/config.yaml, install a hook, or set GBRAIN_URL
via env — can redirect the POST at the operator's own private network
and walk away with both the bearer token and the private brain-repo
URL.

Worst case is the cloud metadata family. Setting
gbrain_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>/
on an EC2 host turns the consumer-registration flow into IAM
credential exfil to whoever runs the listener at the other end.

Threat model

A process or operator that can:

  • Edit ~/.gstack/config.yaml (any local code-execution primitive)
  • Install a malicious hook into the gstack pipeline
  • Set GBRAIN_URL= in the environment of a gstack-brain-init run
    (any shell-rc poisoning, npm postinstall, social engineering of a
    README that says "set this env var")

None of these require root, container escape, or network position. They
turn a config-write into an outbound POST that exfils credentials to
an attacker-chosen internal target.

What changes

validate_gbrain_url() is added to bin/gstack-config and wired into
two paths:

  1. gstack-config set gbrain_url <value> runs the validator at write
    time and refuses to persist a hostile URL.
  2. gstack-brain-init re-validates \$GBRAIN_URL_VAL through the new
    internal subcommand gstack-config __validate-url before the curl
    POST. The env-var path bypasses (1), so this defense-in-depth
    covers the GBRAIN_URL=... runtime override.

Validator rules (all reject by default; opt out with
GSTACK_ALLOW_INTERNAL_URL=1 for local dev against a personal gbrain):

  • require http:// or https:// scheme — no file:, gopher:,
    data:, javascript:, ftp:
  • reject literal local hostnames: localhost, localhost.*,
    *.local, *.localhost, broadcasthost
  • reject IPv4 RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8),
    reserved (0/8), and link-local / cloud IMDS (169.254/16)
  • reject IPv6 loopback ([::1], [::]), link-local ([fe80::*]),
    and unique-local ([fc00::*], [fd*])
  • IPv6 host extraction handles bracketed authorities so [fe80::1]:8443
    extracts as [fe80::1] rather than collapsing to [

Backwards compatibility

  • A normal user running gstack-config set gbrain_url https://gbrain.example.com is unaffected.
  • A dev running a local gbrain at http://127.0.0.1:8080 opts in
    with GSTACK_ALLOW_INTERNAL_URL=1. The error message names the env
    var explicitly so the discovery cost is one error → one re-run.
  • gstack-config get, list, and defaults paths are unchanged.

Test coverage

test/gstack-config-url-ssrf.test.ts — 33 cases following the same
spawnSync + tmp GSTACK_HOME pattern that
gstack-brain-init-gh-mock.test.ts already uses:

  • 8 public URLs accepted (https + http, ports, paths, IPv6 docs)
  • 5 local-only hostnames rejected (localhost flavors)
  • 9 IPv4 hostile addresses rejected (loopback, all three RFC1918
    blocks, IMDS)
  • IPv6 [::1] and [fe80::1] rejected
  • 172.32.x.x accepted (NOT in 172.16-31 range — boundary check)
  • bad shapes rejected: empty, file://, gopher://, data:,
    javascript:, ftp://
  • GSTACK_ALLOW_INTERNAL_URL=1 lets a loopback target through
  • GSTACK_ALLOW_INTERNAL_URL=1 still rejects empty + non-http schemes
  • the set path refuses to write a hostile URL to config.yaml
  • the set path writes a valid public URL through
  • the set path with GSTACK_ALLOW_INTERNAL_URL=1 lets dev URL through

Adjacent tests still pass: explain-level-config,
gstack-brain-init-gh-mock, brain-sync (41/41 across 3 files).

Why this matters

The brain-consumer registration flow trusts whatever endpoint the
operator named, which is the right default for a happy-path setup.
The cost was that any path-of-least-resistance attacker who can
influence the config — a malicious dev tool, a tampered shell rc, an
env var planted by a CI yaml — turns one curl into a bearer-token

  • private-repo-URL exfil to AWS IMDS or any internal target. The
    validator is small (one function, one subcommand, two call sites)
    and additive — every legitimate setup keeps working, and the dev
    escape hatch is one env var away.

…tion

gstack-brain-init posts the brain bearer token + the brain repo URL
to ${gbrain_url}/ingest-repo with no validation of where ${gbrain_url}
points. A process that controls the config — write to ~/.gstack/
config.yaml, install a hook, or set GBRAIN_URL via env — can redirect
the POST at the operator's own private network and walk away with
both the bearer token and the private brain-repo URL.

Worst case is the cloud metadata family: setting
gbrain_url=http://169.254.169.254/latest/meta-data/iam/security-
credentials/<role>/ on an EC2 host turns the consumer-registration
flow into IAM credential exfil to whoever runs the listener.

This change adds validate_gbrain_url() to bin/gstack-config and wires
it into two paths:

  1. `gstack-config set gbrain_url <value>` runs the validator at
     write time and refuses to persist a hostile URL.
  2. `gstack-brain-init` re-validates GBRAIN_URL_VAL through the new
     internal subcommand `gstack-config __validate-url` before the
     curl POST. The env-var path bypasses (1), so this defense-in-
     depth covers the GBRAIN_URL=... runtime override.

Validator rules (all reject by default; opt out with
GSTACK_ALLOW_INTERNAL_URL=1 for local dev):

  - require http:// or https:// scheme (no file:, gopher:, data:,
    javascript:, ftp:)
  - reject literal hostnames that always resolve to local: localhost,
    localhost.*, *.local, *.localhost, broadcasthost
  - reject IPv4 RFC1918 (10/8, 172.16/12, 192.168/16), loopback
    (127/8), reserved (0/8), and link-local / cloud IMDS (169.254/16)
  - reject IPv6 loopback ([::1], [::]), link-local ([fe80::*]) and
    unique-local ([fc00::*], [fd*])
  - IPv6 host extraction handles bracketed authorities so
    `[fe80::1]:8443` extracts as `[fe80::1]` rather than collapsing
    to `[`

Test coverage in test/gstack-config-url-ssrf.test.ts (33 cases):

  - 8 public URLs accepted (https + http, ports, paths, IPv6 docs)
  - 5 local-only hostnames rejected (localhost flavors)
  - 9 IPv4 hostile addresses rejected (loopback, all three RFC1918
    blocks, IMDS)
  - IPv6 [::1] and [fe80::1] rejected
  - 172.32.x.x accepted (NOT in 172.16-31 range — boundary check)
  - bad shapes rejected: empty string, file://, gopher://, data:,
    javascript:, ftp://
  - GSTACK_ALLOW_INTERNAL_URL=1 lets a localhost target through
  - GSTACK_ALLOW_INTERNAL_URL=1 still rejects empty + non-http
  - the set path refuses to write a hostile URL to config.yaml
  - the set path writes a valid public URL through
  - the set path with GSTACK_ALLOW_INTERNAL_URL=1 lets dev URL through

Adjacent tests still pass (explain-level-config, gstack-brain-init-
gh-mock, brain-sync — 41/41 across 3 files).
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