diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0708d63..0ed9022 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,19 +92,44 @@ jobs: with: ref: ${{ github.head_ref || github.ref_name }} persist-credentials: true # needed for git push in commit step - - run: rustup toolchain install stable --profile minimal --target wasm32-wasip1 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + # core.wasm is a build output of the Rust workspace. Rebuilding it on + # every run makes the unpinned stable toolchain push a new binary onto + # PRs that touched no Rust at all. Only rebuild when an input changed. + - name: Detect changes to core.wasm inputs + id: inputs + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [ -z "$BASE" ] || ! git fetch --no-tags --depth=1 origin "$BASE"; then + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --quiet "$BASE" HEAD -- crates Cargo.toml Cargo.lock rust-toolchain rust-toolchain.toml go/ressrf/build_wasm.sh .github/workflows/ci.yml; then + echo "core.wasm inputs unchanged, skipping rebuild" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - if: steps.inputs.outputs.changed == 'true' + run: rustup toolchain install stable --profile minimal --target wasm32-wasip1 + - if: steps.inputs.outputs.changed == 'true' + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Install wasm-opt + if: steps.inputs.outputs.changed == 'true' run: | curl -fsSL https://github.com/WebAssembly/binaryen/releases/download/version_129/binaryen-version_129-x86_64-linux.tar.gz \ | sudo tar -xz -C /usr/local --strip-components=1 - name: Install wasm-tools + if: steps.inputs.outputs.changed == 'true' run: cargo install wasm-tools --locked - name: Rebuild core.wasm + if: steps.inputs.outputs.changed == 'true' run: bash go/ressrf/build_wasm.sh - name: Copy core.wasm to node/ + if: steps.inputs.outputs.changed == 'true' run: cp go/ressrf/core.wasm node/core.wasm - name: Commit if changed + if: steps.inputs.outputs.changed == 'true' run: | if git diff --quiet go/ressrf/core.wasm node/core.wasm; then echo "core.wasm is up to date" diff --git a/crates/ressrf-tcp/tests/ssrf_e2e.rs b/crates/ressrf-tcp/tests/ssrf_e2e.rs index 60575bb..777cafd 100644 --- a/crates/ressrf-tcp/tests/ssrf_e2e.rs +++ b/crates/ressrf-tcp/tests/ssrf_e2e.rs @@ -115,6 +115,8 @@ async fn safe_resolver_pins_resolved_ips_no_toctou() { } impl DnsBackend for RebindingDns { + // The trait method is async; this fake answers without awaiting. + #[allow(clippy::unused_async_trait_impl)] async fn resolve(&self, _host: &str, port: u16) -> std::io::Result> { let n = self.calls.fetch_add(1, Ordering::SeqCst); let ip: std::net::IpAddr = if n == 0 { diff --git a/go-native/ressrf/README.md b/go-native/ressrf/README.md index e2cd7bf..d6ce060 100644 --- a/go-native/ressrf/README.md +++ b/go-native/ressrf/README.md @@ -22,6 +22,7 @@ The workspace also ships a [wazero-backed Go binding](../../go/ressrf/) that run | Optional crypto dep | only `sshx` pulls `golang.org/x/crypto` | always imported | | Policy lifecycle | no `Close()` needed | `Build(ctx)` + `Policy.Close(ctx)` | | Domain-suffix surface | `WithDeniedDomainSuffixes` (additive deny); allow-list seed via `NewAllowListPolicy` constructor parameter; cloud-provider service suffixes available as data via `CloudServiceSuffixes(providers...)` | reachable only via cloud-provider modules, which bundle deny-and-trust | +| `--` host labels | rejected by default; `WithDoubleDashHostsAllowed()` opts out (S3 Express directory buckets) | rejected, no opt-out | Cross-language conformance is preserved end to end: both bindings consume the same 8 JSON vector files at `../../tests/vectors/`, the BS-1..BS-6 URI validator, deny-first / allow-overrides-deny precedence, and the same cloud-provider and IANA deny ranges. The generator under `internal/cmd/gen-data` reads `../../crates/ressrf-core/config/*.json` so both the WASM-backed binding and this native port pick up upstream IP-range refreshes from a single source. diff --git a/go-native/ressrf/internal/engine/audit.go b/go-native/ressrf/internal/engine/audit.go index b5a0128..0810204 100644 --- a/go-native/ressrf/internal/engine/audit.go +++ b/go-native/ressrf/internal/engine/audit.go @@ -6,7 +6,8 @@ type AuditKind string const ( // AuditPolicyCreated fires once per Build() with the policy's preset, - // cloud modules, and deny/allow counts. + // cloud modules, deny/allow counts, and whether "--" host labels are + // allowed. AuditPolicyCreated AuditKind = "policy_created" // AuditURLValidated fires for every IsRequestAllowed call with the URL, // the parsed scheme + host, and an `allowed` flag. diff --git a/go-native/ressrf/internal/engine/policy.go b/go-native/ressrf/internal/engine/policy.go index 01f7c9a..779cf0c 100644 --- a/go-native/ressrf/internal/engine/policy.go +++ b/go-native/ressrf/internal/engine/policy.go @@ -128,6 +128,15 @@ func (b *PolicyBuilder) AddDeniedSuffixes(suffixes ...string) *PolicyBuilder { return b } +// AllowDoubleDashHosts turns off the validator's rejection of host labels +// containing "--" outside the "xn--" punycode prefix. Legitimate hostnames +// carry such labels, e.g. S3 Express directory buckets +// ("name--use1-az4--x-s3.s3express-use1-az4.us-east-1.amazonaws.com"). +func (b *PolicyBuilder) AllowDoubleDashHosts() *PolicyBuilder { + b.validator.SetRejectDoubleDashHosts(false) + return b +} + // AddDomainAllowList registers per-policy allow-list suffixes. Once any // suffix is registered, non-IP hosts that don't match one are rejected with // DomainNotInAllowList. The name says what the call does; for additive @@ -191,6 +200,10 @@ func (b *PolicyBuilder) Build() (*Policy, error) { "cloud_modules": p.cloudModules, "deny_count": p.denySet.Len(), "allow_count": p.allowSet.Len(), + // Operators must be able to see that hostname hardening was + // relaxed. Go native only; the Rust core does not expose the + // setter on its builder yet. + "double_dash_hosts_allowed": !p.validator.rejectDoubleDashHosts, }, }) } diff --git a/go-native/ressrf/internal/engine/uri_validator.go b/go-native/ressrf/internal/engine/uri_validator.go index 49bf7c6..7dba54a 100644 --- a/go-native/ressrf/internal/engine/uri_validator.go +++ b/go-native/ressrf/internal/engine/uri_validator.go @@ -26,6 +26,15 @@ func NewURIValidator() *URIValidator { } } +// SetRejectDoubleDashHosts mirrors UriValidator::reject_double_dash in the +// Rust core. When false, a host label containing "--" outside the "xn--" +// punycode prefix is no longer rejected as HostnameInvalid. Every other check +// (schemes, denied suffixes, bare-IP and CIDR rules, control characters) is +// unchanged. +func (v *URIValidator) SetRejectDoubleDashHosts(reject bool) { + v.rejectDoubleDashHosts = reject +} + func (v *URIValidator) AddDeniedSuffixes(suffixes []string) { for _, s := range suffixes { v.deniedSuffixes = append(v.deniedSuffixes, strings.ToLower(s)) diff --git a/go-native/ressrf/policy.go b/go-native/ressrf/policy.go index f8d2a15..2fd9ab5 100644 --- a/go-native/ressrf/policy.go +++ b/go-native/ressrf/policy.go @@ -157,6 +157,7 @@ type config struct { denyCIDRs []string cloudProviderDenies []string deniedSuffixes []string + allowDoubleDash bool urlAllow []URLRule urlDeny []URLRule sink AuditSink @@ -202,6 +203,31 @@ func WithDeniedDomainSuffixes(suffixes ...string) Option { return func(c *config) { c.deniedSuffixes = append(c.deniedSuffixes, suffixes...) } } +// WithDoubleDashHostsAllowed turns off the rejection of host labels that +// contain "--" outside the "xn--" punycode prefix. The default rejects them +// as HostnameInvalid, which blocks legitimate hostnames such as S3 Express +// directory buckets ("name--use1-az4--x-s3.s3express-use1-az4.us-east-1.amazonaws.com"). +// +// The opt-out is global: every "--" hostname passes this heuristic, not only +// S3 Express, so "https://foo--bar.evil.com/" is no longer rejected on that +// ground. Denied suffixes, bare-IP and CIDR rules, control-character +// rejection, scheme checks, and allow-list mode still apply, so this is not +// an SSRF hole by itself, but for customer-supplied URLs pair it with an +// allow-list policy that names the hosts you expect: +// +// ressrf.NewAllowListPolicy(ressrf.PresetExternalOnly, +// []string{"amazonaws.com"}, +// ressrf.WithDoubleDashHostsAllowed(), +// ressrf.WithCloudProviderDenies(ressrf.CloudAWS), +// ) +// +// Mirrors UriValidator::reject_double_dash(false) in the Rust core. The Rust +// PolicyBuilder and the WASM-backed bindings do not expose that setter yet; +// the Go native port is the only consumer today. +func WithDoubleDashHostsAllowed() Option { + return func(c *config) { c.allowDoubleDash = true } +} + // CloudServiceSuffixes returns the public service-domain suffixes for each // listed provider (e.g. *.amazonaws.com for AWS, *.azurecr.io / *.windows.net // for Azure, *.googleapis.com / *.appspot.com for GCP). The zero-value @@ -309,6 +335,9 @@ func buildPolicy(preset Preset, allowList []string, opts []Option) (*Policy, err if len(cfg.deniedSuffixes) > 0 { core.AddDeniedSuffixes(cfg.deniedSuffixes...) } + if cfg.allowDoubleDash { + core.AllowDoubleDashHosts() + } if len(allowList) > 0 { core.AddDomainAllowList(allowList...) } diff --git a/go-native/ressrf/policy_test.go b/go-native/ressrf/policy_test.go index 2c567eb..880b4fe 100644 --- a/go-native/ressrf/policy_test.go +++ b/go-native/ressrf/policy_test.go @@ -752,3 +752,111 @@ func TestDisableForTests(t *testing.T) { t.Errorf("expected allowed when disabled, got: %v", err) } } + +// TestDoubleDashHosts pins the default rejection of "--" host labels and the +// opt-out. The opt-out must not weaken any other check. +func TestDoubleDashHosts(t *testing.T) { + const s3express = "https://mybucket--use1-az4--x-s3.s3express-use1-az4.us-east-1.amazonaws.com/f.csv" + optOut := []Option{WithDoubleDashHostsAllowed()} + optOutAWS := []Option{WithDoubleDashHostsAllowed(), WithCloudProviderDenies(CloudAWS)} + + cases := []struct { + name string + opts []Option + allowList []string + url string + want DenyReason // nil means allowed + }{ + {name: "default rejects --", url: "https://foo--bar.example.com/", want: &HostnameInvalid{}}, + {name: "default rejects s3express", url: s3express, want: &HostnameInvalid{}}, + {name: "default allows punycode", url: "https://xn--nxasmq6b.example.com/"}, + + {name: "opt-out allows --", opts: optOut, url: "https://foo--bar.example.com/"}, + {name: "opt-out allows s3express", opts: optOut, url: s3express}, + {name: "opt-out allows punycode", opts: optOut, url: "https://xn--nxasmq6b.example.com/"}, + {name: "opt-out in allow-list policy", opts: optOut, allowList: []string{"amazonaws.com"}, url: s3express}, + {name: "opt-out keeps allow-list", opts: optOut, allowList: []string{"example.com"}, url: s3express, want: &DomainNotInAllowList{}}, + + {name: "opt-out keeps denied suffix", opts: optOutAWS, url: "https://foo--bar.compute.internal/", want: &DomainSuffixDenied{}}, + {name: "opt-out keeps metadata IP", opts: optOutAWS, url: "http://169.254.169.254/latest/meta-data/", want: &BareIPDeniedBeforeScheme{}}, + {name: "opt-out keeps control characters in host", opts: optOut, url: "https://foo--bar.example.com%0a/", want: &HostnameInvalid{}}, + {name: "opt-out keeps control characters in path", opts: optOut, url: "https://foo--bar.example.com/pa\nth", want: &HostnameInvalid{}}, + {name: "opt-out keeps scheme check", opts: optOut, url: "ftp://foo--bar.example.com/", want: &SchemeNotAllowed{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var ( + p *Policy + err error + ) + if len(tc.allowList) > 0 { + p, err = NewAllowListPolicy(PresetExternalOnly, tc.allowList, tc.opts...) + } else { + p, err = NewPolicy(PresetExternalOnly, tc.opts...) + } + if err != nil { + t.Fatalf("build: %v", err) + } + + err = p.IsAllowed(t.Context(), tc.url) + if tc.want == nil { + if err != nil { + t.Fatalf("expected allowed, got %v", err) + } + return + } + var blocked *BlockedError + if !errors.As(err, &blocked) { + t.Fatalf("expected *BlockedError, got %v", err) + } + if blocked.Kind() != tc.want.Kind() { + t.Errorf("expected %T, got %T: %v", tc.want, blocked.Reason, blocked) + } + }) + } +} + +// TestPolicyCreatedRecordsDoubleDashOptOut: an operator reading the audit +// stream must see when hostname hardening was relaxed. +func TestPolicyCreatedRecordsDoubleDashOptOut(t *testing.T) { + cases := []struct { + name string + opts []Option + want bool + }{ + {name: "default", want: false}, + {name: "opt-out", opts: []Option{WithDoubleDashHostsAllowed()}, want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var events []AuditEvent + sink := AuditFunc(func(_ context.Context, e *AuditEvent) { + mu.Lock() + events = append(events, *e) + mu.Unlock() + }) + if _, err := NewPolicy(PresetExternalOnly, append(tc.opts, WithAuditSink(sink))...); err != nil { + t.Fatalf("build: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(events) != 1 || events[0].Kind != "policy_created" { + t.Fatalf("expected one policy_created event, got %+v", events) + } + var fields struct { + DoubleDashHostsAllowed bool `json:"double_dash_hosts_allowed"` + } + if err := json.Unmarshal(events[0].Fields, &fields); err != nil { + t.Fatalf("decode fields: %v", err) + } + if fields.DoubleDashHostsAllowed != tc.want { + t.Errorf("double_dash_hosts_allowed = %v, want %v", fields.DoubleDashHostsAllowed, tc.want) + } + }) + } +} diff --git a/go/ressrf/core.wasm b/go/ressrf/core.wasm index 1f86dcd..6d28c51 100644 Binary files a/go/ressrf/core.wasm and b/go/ressrf/core.wasm differ diff --git a/node/core.wasm b/node/core.wasm index 1f86dcd..6d28c51 100644 Binary files a/node/core.wasm and b/node/core.wasm differ