Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions crates/ressrf-tcp/tests/ssrf_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<SocketAddr>> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
let ip: std::net::IpAddr = if n == 0 {
Expand Down
1 change: 1 addition & 0 deletions go-native/ressrf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion go-native/ressrf/internal/engine/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions go-native/ressrf/internal/engine/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
mostafa marked this conversation as resolved.
Resolved

// 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
Expand Down Expand Up @@ -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,
},
})
}
Expand Down
9 changes: 9 additions & 0 deletions go-native/ressrf/internal/engine/uri_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
29 changes: 29 additions & 0 deletions go-native/ressrf/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ type config struct {
denyCIDRs []string
cloudProviderDenies []string
deniedSuffixes []string
allowDoubleDash bool
urlAllow []URLRule
urlDeny []URLRule
sink AuditSink
Expand Down Expand Up @@ -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 }
}
Comment thread
mostafa marked this conversation as resolved.
Resolved

// 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
Expand Down Expand Up @@ -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...)
}
Expand Down
108 changes: 108 additions & 0 deletions go-native/ressrf/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment thread
mostafa marked this conversation as resolved.
Resolved
{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)
}
})
}
}
Binary file modified go/ressrf/core.wasm
Binary file not shown.
Binary file modified node/core.wasm
Binary file not shown.
Loading