security: reject internal-network gbrain_url before consumer registration#1211
Open
garagon wants to merge 1 commit intogarrytan:mainfrom
Open
security: reject internal-network gbrain_url before consumer registration#1211garagon wants to merge 1 commit intogarrytan:mainfrom
garagon wants to merge 1 commit intogarrytan:mainfrom
Conversation
…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).
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.
Summary
gstack-brain-initPOSTs the brain bearer token + the brain repo URLto
\${gbrain_url}/ingest-repowith no validation of where\${gbrain_url}points. Anyone with the power to set the config —write
~/.gstack/config.yaml, install a hook, or setGBRAIN_URLvia 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:
~/.gstack/config.yaml(any local code-execution primitive)GBRAIN_URL=in the environment of agstack-brain-initrun(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 tobin/gstack-configand wired intotwo paths:
gstack-config set gbrain_url <value>runs the validator at writetime and refuses to persist a hostile URL.
gstack-brain-initre-validates\$GBRAIN_URL_VALthrough the newinternal subcommand
gstack-config __validate-urlbefore the curlPOST. 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=1for local dev against a personal gbrain):http://orhttps://scheme — nofile:,gopher:,data:,javascript:,ftp:localhost,localhost.*,*.local,*.localhost,broadcasthostreserved (0/8), and link-local / cloud IMDS (169.254/16)
[::1],[::]), link-local ([fe80::*]),and unique-local (
[fc00::*],[fd*])[fe80::1]:8443extracts as
[fe80::1]rather than collapsing to[Backwards compatibility
gstack-config set gbrain_url https://gbrain.example.comis unaffected.http://127.0.0.1:8080opts inwith
GSTACK_ALLOW_INTERNAL_URL=1. The error message names the envvar explicitly so the discovery cost is one error → one re-run.
gstack-config get,list, anddefaultspaths are unchanged.Test coverage
test/gstack-config-url-ssrf.test.ts— 33 cases following the samespawnSync + tmp
GSTACK_HOMEpattern thatgstack-brain-init-gh-mock.test.tsalready uses:blocks, IMDS)
[::1]and[fe80::1]rejected172.32.x.xaccepted (NOT in172.16-31range — boundary check)file://,gopher://,data:,javascript:,ftp://GSTACK_ALLOW_INTERNAL_URL=1lets a loopback target throughGSTACK_ALLOW_INTERNAL_URL=1still rejects empty + non-http schemesGSTACK_ALLOW_INTERNAL_URL=1lets dev URL throughAdjacent 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
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.