From 4bf2c436e9ade1a8cedafe4d037216c3331a16aa Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:15:18 -0400 Subject: [PATCH 1/7] vm: runner-owned adapter deadline (AdapterDeadline, -adapter-deadline flag value), hard cap 30s (#175) --- internal/hfir/adapter_deadline_test.go | 112 +++++++++++++++++++++++++ internal/vm/error.go | 49 +++++++++++ internal/vm/vm.go | 11 ++- 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 internal/hfir/adapter_deadline_test.go diff --git a/internal/hfir/adapter_deadline_test.go b/internal/hfir/adapter_deadline_test.go new file mode 100644 index 00000000..d39a7115 --- /dev/null +++ b/internal/hfir/adapter_deadline_test.go @@ -0,0 +1,112 @@ +package hfir + +import ( + "flag" + "io" + "strings" + "testing" + "time" + + "github.com/howlcipher/howlframe/internal/bytecode" + "github.com/howlcipher/howlframe/internal/vm" +) + +func adapterProgram(t *testing.T, source string) *bytecode.BCProgram { + t.Helper() + data, err := EncodeCandidateTransport(parseAndLower(t, source, "adapter_deadline.howl", false)) + if err != nil { + t.Fatal(err) + } + c, ds := DecodeCandidate(data) + if len(ds) > 0 { + t.Fatal(ds) + } + p, ds := CompileCandidate(c) + if len(ds) > 0 { + t.Fatal(ds) + } + return p +} + +// #175: the runner owns the adapter budget; it can raise it up to 30s. +func TestAdapterDeadlineRunnerOwned(t *testing.T) { + for _, tc := range []struct { + name string + adapter, deadline time.Duration + source string + wantMS int64 + wantCode, wantOut string + }{ + {"default_is_1s", 0, 0, `(cli_app (print 1))`, 1000, "", "1\n"}, + {"raised_budget_allows_sleep", 1500 * time.Millisecond, 0, `(cli_app (sleep 1200) (print 7))`, 1500, "", "7\n"}, + {"over_cap_clamped", time.Hour, 0, `(cli_app (print 1))`, 30000, "", "1\n"}, + {"over_cap_sleep_refused", time.Hour, 0, `(cli_app (sleep 31000) (print 7))`, 30000, "HFIR_DEADLINE_EXCEEDED", ""}, + {"shorter_runner_deadline_wins", 10 * time.Second, 50 * time.Millisecond, `(cli_app (print 1))`, 50, "", "1\n"}, + {"longer_runner_deadline_capped_by_budget", 0, 5 * time.Second, `(cli_app (print 1))`, 1000, "", "1\n"}, + {"runner_deadline_over_hard_cap", time.Hour, time.Hour, `(cli_app (print 1))`, 30000, "", "1\n"}, + {"default_still_refuses_long_sleep", 0, 0, `(cli_app (sleep 1200) (print 7))`, 1000, "HFIR_DEADLINE_EXCEEDED", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + policy := vm.DefaultExecutionPolicy() + policy.AdapterDeadline = tc.adapter + policy.Deadline = tc.deadline + var out strings.Builder + start := time.Now() + _, receipt := vm.RunBytecodeWithReceipt(adapterProgram(t, tc.source), nil, policy, nil, strings.NewReader(""), &out, io.Discard, "test", "test") + if receipt.Limits.DeadlineMS != tc.wantMS { + t.Fatalf("effective deadline %dms, want %dms", receipt.Limits.DeadlineMS, tc.wantMS) + } + code := "" + if receipt.Exit.Error != nil { + code = receipt.Exit.Error.Code + } + if code != tc.wantCode || out.String() != tc.wantOut { + t.Fatalf("code=%q out=%q", code, out.String()) + } + if tc.wantCode != "" && time.Since(start) > 500*time.Millisecond { + t.Fatal("over-budget sleep was not refused immediately") + } + }) + } +} + +// AdapterDeadline never constrains or extends ordinary artifact runs. +func TestAdapterDeadlineIgnoredForArtifacts(t *testing.T) { + p, ds := LowerToBytecode(parseAndLower(t, `(cli_app (print 1))`, "artifact.howl", false)) + if len(ds) > 0 { + t.Fatal(ds) + } + policy := vm.DefaultExecutionPolicy() + policy.AdapterDeadline = 2 * time.Second + _, receipt := vm.RunBytecodeWithReceipt(p, nil, policy, nil, strings.NewReader(""), io.Discard, io.Discard, "test", "test") + if receipt.Limits.DeadlineMS != 0 { + t.Fatalf("artifact run got adapter deadline %d", receipt.Limits.DeadlineMS) + } +} + +func TestAdapterDeadlineFlag(t *testing.T) { + for raw, ok := range map[string]bool{"30s": true, "250ms": true, "30.001s": false, "31s": false, "1h": false, "0s": false, "-1s": false, "soon": false} { + policy := vm.DefaultExecutionPolicy() + fs := flag.NewFlagSet("runner", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.Var(vm.AdapterDeadlineFlag{Policy: &policy}, "adapter-deadline", "") + err := fs.Parse([]string{"-adapter-deadline", raw}) + if ok != (err == nil) { + t.Errorf("%s: err=%v", raw, err) + } + if ok && policy.AdapterDeadline.String() != mustDuration(t, raw).String() { + t.Errorf("%s: stored %s", raw, policy.AdapterDeadline) + } + if !ok && policy.AdapterDeadline != 0 { + t.Errorf("%s: rejected value stored %s", raw, policy.AdapterDeadline) + } + } +} + +func mustDuration(t *testing.T, s string) time.Duration { + d, err := time.ParseDuration(s) + if err != nil { + t.Fatal(err) + } + return d +} diff --git a/internal/vm/error.go b/internal/vm/error.go index 5f262e6b..b3526505 100644 --- a/internal/vm/error.go +++ b/internal/vm/error.go @@ -52,6 +52,55 @@ type ExecutionPolicy struct { Limits VMLimits // Deadline is an optional wall-clock duration; zero means no deadline. Deadline time.Duration + // AdapterDeadline is the runner-owned wall-clock budget for programs + // produced by hfir.CompileCandidate (#175). Zero means + // DefaultAdapterDeadline; values above MaxAdapterDeadline are clamped to + // it. Model-authored candidates cannot set or widen it. A shorter + // Deadline still applies. + AdapterDeadline time.Duration +} + +// DefaultAdapterDeadline is the model-adapter budget when the runner sets none. +const DefaultAdapterDeadline = time.Second + +// MaxAdapterDeadline is the hard cap on a runner-owned adapter budget (#175). +const MaxAdapterDeadline = 30 * time.Second + +// ParseAdapterDeadline validates a runner flag value such as "5s". It refuses +// zero, negative and over-cap durations instead of clamping them, so a runner +// typo cannot silently change the budget. +func ParseAdapterDeadline(raw string) (time.Duration, error) { + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("-adapter-deadline invalid duration: %w", err) + } + if d <= 0 { + return 0, fmt.Errorf("-adapter-deadline must be greater than zero") + } + if d > MaxAdapterDeadline { + return 0, fmt.Errorf("-adapter-deadline %s exceeds the %s hard cap", d, MaxAdapterDeadline) + } + return d, nil +} + +// AdapterDeadlineFlag is a flag.Value for a runner's -adapter-deadline flag. +// It writes the validated budget into the runner's ExecutionPolicy. +type AdapterDeadlineFlag struct{ Policy *ExecutionPolicy } + +func (f AdapterDeadlineFlag) String() string { + if f.Policy == nil || f.Policy.AdapterDeadline == 0 { + return DefaultAdapterDeadline.String() + } + return f.Policy.AdapterDeadline.String() +} + +func (f AdapterDeadlineFlag) Set(raw string) error { + d, err := ParseAdapterDeadline(raw) + if err != nil { + return err + } + f.Policy.AdapterDeadline = d + return nil } var DefaultLimits = VMLimits{ diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 1222cd58..da55288d 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -4050,8 +4050,15 @@ func TextOf(v any) string { } func effectiveExecutionPolicy(prog *bytecode.BCProgram, policy ExecutionPolicy) ExecutionPolicy { - if prog.IsModelAdapter() && (policy.Deadline <= 0 || policy.Deadline > time.Second) { - policy.Deadline = time.Second + if !prog.IsModelAdapter() { + return policy + } + budget := DefaultAdapterDeadline + if policy.AdapterDeadline > 0 { + budget = min(policy.AdapterDeadline, MaxAdapterDeadline) + } + if policy.Deadline <= 0 || policy.Deadline > budget { + policy.Deadline = budget } return policy } From 4f31849213800f55e2310f7f79afefdc3070a18e Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:20:20 -0400 Subject: [PATCH 2/7] capability/gogen/js: single-source process:env refusal tables (GIT_*, PATH, HOME, XDG, PERL5DB, GCONV_PATH, JAVA opts, shell opts...) + parity tests (#177) --- internal/backend/gogen/gogen.go | 34 +++++++++++-- .../backend/gogen/network_security_test.go | 27 +++++++--- internal/backend/javascript/javascript.go | 28 +++++++++- .../javascript/network_security_test.go | 43 ++++++++++++++-- internal/capability/grants.go | 2 +- internal/capability/network_security_test.go | 22 +++++--- internal/capability/network_test.go | 6 ++- internal/capability/process.go | 51 +++++++++++++++---- internal/nettest/env.go | 20 ++++++++ issue165_netscope_cli_test.go | 10 +++- 10 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 internal/nettest/env.go diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index e8c44fd8..fc3bc70d 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -525,7 +525,7 @@ func envHelperSource() string { // is spawned only after that grant is present, so a denial cannot run it // or include the command in the error. func execHelperSource() string { - return `// howlFrameExecAllowed mirrors capability.Grants.AuthorizeExec: the program + return codeLoadingEnvGoSource() + `// howlFrameExecAllowed mirrors capability.Grants.AuthorizeExec: the program // must be an absolute, clean, symlink-free path named by a process:exec= // grant whose argument pattern matches argv exactly. A bare process grant // never authorizes exec. @@ -590,10 +590,18 @@ func howlFrameExecSlot(p, arg string) bool { } // howlFrameExecEnv passes only process:env=NAME variables; never the host env. +// howlFrameCodeLoadingEnv mirrors capability.DynamicLoaderEnv over the shared tables. func howlFrameCodeLoadingEnv(name string) bool { - switch strings.ToUpper(name) { - case "PYTHONPATH", "NODE_OPTIONS", "BASH_ENV", "ENV", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "PYTHONSTARTUP", "NODE_PATH": - return true + u := strings.ToUpper(name) + for _, n := range howlFrameCodeLoadingEnvNames { + if u == n { + return true + } + } + for _, p := range howlFrameCodeLoadingEnvPrefixes { + if strings.HasPrefix(u, p) { + return true + } } return false } @@ -602,7 +610,7 @@ func howlFrameExecEnv() []string { env := []string{} for _, grant := range strings.Split(os.Getenv("HOWLFRAME_ALLOW_CAPS"), ",") { name, ok := strings.CutPrefix(strings.TrimSpace(grant), "process:env=") - if !ok || name == "" || strings.ContainsAny(name, "= ") || strings.HasPrefix(strings.ToUpper(name), "LD_") || strings.HasPrefix(strings.ToUpper(name), "DYLD_") || howlFrameCodeLoadingEnv(name) { + if !ok || name == "" || strings.ContainsAny(name, "= ") || howlFrameCodeLoadingEnv(name) { continue } if v, ok := os.LookupEnv(name); ok { @@ -676,6 +684,22 @@ func fetchImports(extra []string) string { return out } +// codeLoadingEnvGoSource renders capability.CodeLoadingEnvNames and +// CodeLoadingEnvPrefixes, the refusal tables shared by every target. +func codeLoadingEnvGoSource() string { + var b strings.Builder + b.WriteString("var howlFrameCodeLoadingEnvNames = []string{\n") + for _, n := range capability.CodeLoadingEnvNames { + fmt.Fprintf(&b, "\t%q,\n", n) + } + b.WriteString("}\n\nvar howlFrameCodeLoadingEnvPrefixes = []string{\n") + for _, p := range capability.CodeLoadingEnvPrefixes { + fmt.Fprintf(&b, "\t%q,\n", p) + } + b.WriteString("}\n\n") + return b.String() +} + // specialPrefixGoSource renders capability.SpecialPrefixes, the one table // shared by every target, into the emitted helper. func specialPrefixGoSource() string { diff --git a/internal/backend/gogen/network_security_test.go b/internal/backend/gogen/network_security_test.go index 3c248c67..5446a93a 100644 --- a/internal/backend/gogen/network_security_test.go +++ b/internal/backend/gogen/network_security_test.go @@ -18,7 +18,9 @@ func TestGoFetchSecurity(t *testing.T) { source := `package consumer import ( "context"; "errors"; "fmt"; "io"; "net"; "net/http"; "net/netip"; "net/url"; "os"; "os/exec"; "strconv"; "strings"; "syscall"; "time"; "testing" + "github.com/howlcipher/howlframe/internal/capability" "github.com/howlcipher/howlframe/internal/nettest" + "reflect" ) ` + fetchHelperSource() + execHelperSource() + ` func TestSecurity(t *testing.T) { @@ -32,13 +34,26 @@ func TestSecurity(t *testing.T) { }) } func TestCodeLoadingEnv(t *testing.T) { - for _,name:=range []string{"PYTHONPATH","NODE_OPTIONS","BASH_ENV","ENV","PERL5LIB","PERL5OPT","RUBYOPT","RUBYLIB","PYTHONSTARTUP","NODE_PATH"} { - for _,raw:=range []string{name,strings.ToLower(name)} { - t.Setenv(raw,"injected") - t.Setenv("HOWLFRAME_ALLOW_CAPS","process:env="+raw) - if env:=howlFrameExecEnv();len(env)!=0 {t.Fatalf("code-loading variable passed: %v",env)} - } + for _,name:=range nettest.EnvRefused { + t.Setenv(name,"injected") + t.Setenv("HOWLFRAME_ALLOW_CAPS","process:env="+name) + if env:=howlFrameExecEnv();len(env)!=0 {t.Fatalf("code-loading variable passed: %v",env)} } + for _,name:=range nettest.EnvAllowed { + t.Setenv(name,"ok") + t.Setenv("HOWLFRAME_ALLOW_CAPS","process:env="+name) + if env:=howlFrameExecEnv();len(env)!=1||env[0]!=name+"=ok" {t.Fatalf("benign variable %s refused: %v",name,env)} + } +} +func TestSharedTables(t *testing.T) { + if !reflect.DeepEqual(howlFrameCodeLoadingEnvNames,capability.CodeLoadingEnvNames)||!reflect.DeepEqual(howlFrameCodeLoadingEnvPrefixes,capability.CodeLoadingEnvPrefixes) {t.Fatal("env tables drifted")} + got:=[]string{} + for _,p:=range howlFrameSpecialPrefixes {got=append(got,p.String())} + if !reflect.DeepEqual(got,capability.SpecialPrefixes) {t.Fatalf("prefix table drifted: %v",got)} +} +func TestAddressLists(t *testing.T) { + for _,ip:=range nettest.BlockedAddresses {if !howlFrameForbiddenDest(netip.MustParseAddr(ip)) {t.Errorf("allowed %s",ip)}} + for _,ip:=range nettest.PublicAddresses {if howlFrameForbiddenDest(netip.MustParseAddr(ip)) {t.Errorf("blocked public %s",ip)}} } ` if err := os.WriteFile(filepath.Join(dir, "security_test.go"), []byte(source), 0600); err != nil { diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index 5ba6ff1a..19bd79bd 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -322,7 +322,7 @@ func envJSHelper() string { // put the command in the error. There is no shell. Stdout is returned // ahead of stderr, which matches CombinedOutput when only one stream writes. func execJSHelper() string { - return `function howlFrameExecAllowed(cmd, args) { + return codeLoadingEnvJSSource() + `function howlFrameExecAllowed(cmd, args) { var fs = require("fs"); if (typeof cmd !== "string" || cmd.charAt(0) !== "/") return false; var prefix = ""; @@ -366,6 +366,16 @@ function howlFrameExecSlot(p, arg) { return p === arg; } +// howlFrameCodeLoadingEnv mirrors capability.DynamicLoaderEnv over the shared tables. +function howlFrameCodeLoadingEnv(name) { + var u = String(name).toUpperCase(); + if (howlFrameCodeLoadingEnvNames.indexOf(u) >= 0) return true; + for (var i = 0; i < howlFrameCodeLoadingEnvPrefixes.length; i++) { + if (u.indexOf(howlFrameCodeLoadingEnvPrefixes[i]) === 0) return true; + } + return false; +} + // Only process:env=NAME variables reach the child; never the host environment. function howlFrameExecEnv() { var env = {}; @@ -374,7 +384,7 @@ function howlFrameExecEnv() { var grant = grants[i].trim(); if (grant.indexOf("process:env=") !== 0) continue; var name = grant.slice("process:env=".length); - if (name === "" || /[= ]/.test(name) || /^(LD_|DYLD_)/i.test(name) || /^(PYTHONPATH|NODE_OPTIONS|BASH_ENV|ENV|PERL5LIB|PERL5OPT|RUBYOPT|RUBYLIB|PYTHONSTARTUP|NODE_PATH)$/i.test(name)) continue; + if (name === "" || /[= ]/.test(name) || howlFrameCodeLoadingEnv(name)) continue; if (Object.prototype.hasOwnProperty.call(process.env, name)) env[name] = process.env[name]; } return env; @@ -1478,3 +1488,17 @@ func specialPrefixJSSource() string { b.WriteString("];\n\n") return b.String() } + +// codeLoadingEnvJSSource renders capability.CodeLoadingEnvNames and +// CodeLoadingEnvPrefixes, the refusal tables shared by every target. +func codeLoadingEnvJSSource() string { + quote := func(xs []string) string { + out := make([]string, len(xs)) + for i, x := range xs { + out[i] = strconv.Quote(x) + } + return strings.Join(out, ", ") + } + return "var howlFrameCodeLoadingEnvNames = [" + quote(capability.CodeLoadingEnvNames) + "];\n" + + "var howlFrameCodeLoadingEnvPrefixes = [" + quote(capability.CodeLoadingEnvPrefixes) + "];\n\n" +} diff --git a/internal/backend/javascript/network_security_test.go b/internal/backend/javascript/network_security_test.go index 231e0514..cc3c406e 100644 --- a/internal/backend/javascript/network_security_test.go +++ b/internal/backend/javascript/network_security_test.go @@ -1,10 +1,13 @@ package javascript import ( + "encoding/json" + "github.com/howlcipher/howlframe/internal/capability" "io" "net" "net/http" "net/http/httptest" + "net/netip" "strconv" "strings" "sync/atomic" @@ -149,15 +152,45 @@ func TestJSAmbiguousNumericGrant(t *testing.T) { `) } func TestJSCodeLoadingEnv(t *testing.T) { + refused, _ := json.Marshal(nettest.EnvRefused) + allowed, _ := json.Marshal(nettest.EnvAllowed) securityJS(t, ` - for(const name of ["PYTHONPATH","NODE_OPTIONS","BASH_ENV","ENV","PERL5LIB","PERL5OPT","RUBYOPT","RUBYLIB","PYTHONSTARTUP","NODE_PATH"]) { - for(const raw of [name,name.toLowerCase()]) { - process.env[raw]="injected";process.env.HOWLFRAME_ALLOW_CAPS="process:env="+raw; - assert.deepEqual(howlFrameExecEnv(),{},raw);delete process.env[raw]; - } + for(const raw of `+string(refused)+`) { + process.env[raw]="injected";process.env.HOWLFRAME_ALLOW_CAPS="process:env="+raw; + assert.deepEqual(howlFrameExecEnv(),{},raw);delete process.env[raw]; + } + for(const raw of `+string(allowed)+`) { + process.env[raw]="ok";process.env.HOWLFRAME_ALLOW_CAPS="process:env="+raw; + assert.deepEqual(howlFrameExecEnv(),{[raw]:"ok"},raw);delete process.env[raw]; } `) } + +// The emitted tables are exactly capability's single-source tables. +func TestJSSharedTables(t *testing.T) { + names, _ := json.Marshal(capability.CodeLoadingEnvNames) + prefixes, _ := json.Marshal(capability.CodeLoadingEnvPrefixes) + var rows [][2]any + for _, raw := range capability.SpecialPrefixes { + p := netip.MustParsePrefix(raw) + bits := p.Bits() + if p.Addr().Is4() { + bits += 96 + } + b := p.Addr().As16() + ints := make([]int, 16) + for i := range b { + ints[i] = int(b[i]) + } + rows = append(rows, [2]any{ints, bits}) + } + special, _ := json.Marshal(rows) + securityJS(t, ` + assert.deepEqual(howlFrameCodeLoadingEnvNames,`+string(names)+`); + assert.deepEqual(howlFrameCodeLoadingEnvPrefixes,`+string(prefixes)+`); + assert.deepEqual(howlFrameSpecialPrefixes,`+string(special)+`); +`) +} func TestJSProxyEnvIgnored(t *testing.T) { securityJS(t, jsServers+` let connections=0; diff --git a/internal/capability/grants.go b/internal/capability/grants.go index a5fdacc7..7e0f02bf 100644 --- a/internal/capability/grants.go +++ b/internal/capability/grants.go @@ -17,7 +17,7 @@ func ParseGrant(raw string) (Capability, error) { } if name, ok := strings.CutPrefix(raw, EnvGrantPrefix); ok { if DynamicLoaderEnv(name) { - return None, fmt.Errorf("process:env= refuses code-loading variables (including LD_*, DYLD_*): %q", raw) + return None, fmt.Errorf("process:env= refuses code-loading variables (including LD_*, DYLD_*, GIT_*, PATH, HOME): %q", raw) } if !validEnvName(name) { return None, fmt.Errorf("unknown capability: %q", raw) diff --git a/internal/capability/network_security_test.go b/internal/capability/network_security_test.go index c3d45eb9..a7f20f29 100644 --- a/internal/capability/network_security_test.go +++ b/internal/capability/network_security_test.go @@ -42,14 +42,20 @@ func TestFetchTrailingDotDiagnostic(t *testing.T) { } } func TestEnvGrantRefusesCodeLoading(t *testing.T) { - for _, name := range []string{"PYTHONPATH", "NODE_OPTIONS", "BASH_ENV", "ENV", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "PYTHONSTARTUP", "NODE_PATH"} { - for _, raw := range []string{name, strings.ToLower(name)} { - if _, err := ParseGrant(EnvGrantPrefix + raw); err == nil { - t.Errorf("allowed %s", raw) - } - if len((Grants{Capability(EnvGrantPrefix + raw)}).ExecEnv(func(string) (string, bool) { return "injected", true })) != 0 { - t.Errorf("raw grant allowed %s", raw) - } + for _, raw := range nettest.EnvRefused { + if _, err := ParseGrant(EnvGrantPrefix + raw); err == nil { + t.Errorf("allowed %s", raw) + } + if len((Grants{Capability(EnvGrantPrefix + raw)}).ExecEnv(func(string) (string, bool) { return "injected", true })) != 0 { + t.Errorf("raw grant allowed %s", raw) + } + } + for _, raw := range nettest.EnvAllowed { + if _, err := ParseGrant(EnvGrantPrefix + raw); err != nil { + t.Errorf("refused benign %s: %v", raw, err) + } + if env := (Grants{Capability(EnvGrantPrefix + raw)}).ExecEnv(func(string) (string, bool) { return "ok", true }); len(env) != 1 { + t.Errorf("benign %s not passed: %v", raw, env) } } } diff --git a/internal/capability/network_test.go b/internal/capability/network_test.go index 94a359ea..85d08e4a 100644 --- a/internal/capability/network_test.go +++ b/internal/capability/network_test.go @@ -163,7 +163,11 @@ func TestEnvGrantRefusesDynamicLoader(t *testing.T) { t.Errorf("ExecEnv passed %s", name) } } - if _, err := ParseGrant(EnvGrantPrefix + "HOME"); err != nil { + // #177: HOME steers config/startup lookup and is refused; LANG still passes. + if _, err := ParseGrant(EnvGrantPrefix + "HOME"); err == nil { + t.Error("process:env=HOME accepted") + } + if _, err := ParseGrant(EnvGrantPrefix + "LANG"); err != nil { t.Error(err) } if _, err := ParseGrant(EnvGrantPrefix + "OLD_PATH"); err != nil { diff --git a/internal/capability/process.go b/internal/capability/process.go index 1b645e0c..33c0bae8 100644 --- a/internal/capability/process.go +++ b/internal/capability/process.go @@ -91,18 +91,51 @@ func slotMatches(p, arg string) bool { // exec child. exec children otherwise start with an empty environment. const EnvGrantPrefix = "process:env=" -// DynamicLoaderEnv reports variables that steer the dynamic loader -// or interpreter code loading (LD_*, DYLD_*, PYTHONPATH, NODE_OPTIONS, etc.). -// Passing one to an exec -// child would let it load arbitrary code into an allow-listed program, so -// process:env= refuses them. +// CodeLoadingEnvNames and CodeLoadingEnvPrefixes are the single source of +// the process:env= refusal list (#166, #172, #177). Generated Go and +// JavaScript render these same tables, so the three targets cannot drift. +// Matching is case-insensitive. Each entry steers code loading, startup +// scripts, config or trust lookup, or program search in an exec child. +var CodeLoadingEnvNames = []string{ + // shells + "BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS", "PS4", "PROMPT_COMMAND", "IFS", "CDPATH", "GLOBIGNORE", "ZDOTDIR", + // program search, home and config directories + "PATH", "HOME", "XDG_CONFIG_HOME", "XDG_CONFIG_DIRS", "XDG_DATA_HOME", "XDG_DATA_DIRS", + // Python + "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE", "PYTHONINSPECT", "PYTHONWARNINGS", "PYTHONBREAKPOINT", "PYTHONEXECUTABLE", + // Node + "NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_REPL_EXTERNAL_MODULE", + // Perl and Ruby + "PERL5LIB", "PERLLIB", "PERL5OPT", "PERL5DB", "RUBYOPT", "RUBYLIB", + // Go and Java + "GOFLAGS", "GODEBUG", "GOROOT", "GOPATH", "GOTOOLCHAIN", "GOENV", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "CLASSPATH", + // libc locale, iconv and resolver lookups + "GCONV_PATH", "LOCPATH", "NLSPATH", "HOSTALIASES", "RESOLV_HOST_CONF", + // TLS trust stores + "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", +} + +// CodeLoadingEnvPrefixes refuses whole families: the dynamic loader (LD_*, +// DYLD_*), git hooks/config/exec path (GIT_*), exported bash functions +// (BASH_FUNC_*) and Lua module paths and init (LUA_*). +var CodeLoadingEnvPrefixes = []string{"LD_", "DYLD_", "GIT_", "BASH_FUNC_", "LUA_"} + +// DynamicLoaderEnv reports variables in the shared code-loading refusal +// list. Passing one to an exec child would let it load arbitrary code into +// an allow-listed program, so process:env= refuses them. func DynamicLoaderEnv(name string) bool { u := strings.ToUpper(name) - switch u { - case "PYTHONPATH", "NODE_OPTIONS", "BASH_ENV", "ENV", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "PYTHONSTARTUP", "NODE_PATH": - return true + for _, n := range CodeLoadingEnvNames { + if u == n { + return true + } } - return strings.HasPrefix(u, "LD_") || strings.HasPrefix(u, "DYLD_") + for _, p := range CodeLoadingEnvPrefixes { + if strings.HasPrefix(u, p) { + return true + } + } + return false } func validEnvName(name string) bool { diff --git a/internal/nettest/env.go b/internal/nettest/env.go new file mode 100644 index 00000000..9123f72f --- /dev/null +++ b/internal/nettest/env.go @@ -0,0 +1,20 @@ +package nettest + +// EnvRefused are process:env= names every target must refuse (#166, #172, +// #177). The list is independent of capability.CodeLoadingEnvNames on +// purpose: deleting a table row must fail the parity tests. +var EnvRefused = []string{ + "LD_PRELOAD", "ld_library_path", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", + "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "pythonhome", "PYTHONWARNINGS", "PYTHONBREAKPOINT", + "NODE_OPTIONS", "NODE_PATH", "NODE_EXTRA_CA_CERTS", "node_options", + "BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS", "PS4", "PROMPT_COMMAND", "IFS", "BASH_FUNC_x", + "PERL5LIB", "PERLLIB", "PERL5OPT", "PERL5DB", "RUBYOPT", "RUBYLIB", + "GOFLAGS", "GODEBUG", "GOROOT", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS", "CLASSPATH", + "GCONV_PATH", "LOCPATH", "HOSTALIASES", + "GIT_DIR", "GIT_EXEC_PATH", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "git_askpass", + "HOME", "home", "XDG_CONFIG_HOME", "XDG_CONFIG_DIRS", "PATH", "path", + "SSL_CERT_FILE", "SSL_CERT_DIR", "LUA_INIT", "LUA_PATH", +} + +// EnvAllowed must still pass through on every target. +var EnvAllowed = []string{"LANG", "TZ", "HOWL_TOKEN", "LOGNAME", "PATHNAME", "HOMEDIR", "GITHUB_SHA", "XDG_RUNTIME_DIR"} diff --git a/issue165_netscope_cli_test.go b/issue165_netscope_cli_test.go index 69171998..9e651272 100644 --- a/issue165_netscope_cli_test.go +++ b/issue165_netscope_cli_test.go @@ -232,8 +232,14 @@ func TestIssue166EnvGrantRefusesLoaderVars(t *testing.T) { t.Fatalf("process:env=%s accepted: %d %q %q", name, r.code, r.stdout, r.stderr) } } - if r := runAuthority(t, binary, "bytecode", `(cli_app (print "ran"))`, "process:env=HOME"); r.code != 0 { - t.Fatalf("process:env=HOME refused: %q %q", r.stdout, r.stderr) + // #177: HOME, PATH and GIT_* steer config or program lookup and are refused. + for _, name := range []string{"HOME", "PATH", "GIT_DIR", "XDG_CONFIG_HOME", "JAVA_TOOL_OPTIONS"} { + if r := runAuthority(t, binary, "bytecode", `(cli_app (print "ran"))`, "process:env="+name); r.code == 0 || strings.Contains(r.stdout, "ran") { + t.Fatalf("process:env=%s accepted: %d %q %q", name, r.code, r.stdout, r.stderr) + } + } + if r := runAuthority(t, binary, "bytecode", `(cli_app (print "ran"))`, "process:env=LANG"); r.code != 0 { + t.Fatalf("process:env=LANG refused: %q %q", r.stdout, r.stderr) } } From 52f1ebdabd2f9fa1399f18e8cdd1f3b278549e6c Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:21:56 -0400 Subject: [PATCH 3/7] capability/gogen/js: decode IPv4-translated ::ffff:0:0/96; add 3fff::/20 and 5f00::/16; run address lists on generated Go (#177) --- internal/backend/gogen/gogen.go | 7 +++++++ internal/backend/javascript/javascript.go | 2 ++ internal/capability/destinations.go | 18 ++++++++++++++++-- internal/nettest/security.go | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index fc3bc70d..bcfbed40 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -724,6 +724,13 @@ func howlFrameForbiddenDest(a netip.Addr) bool { if compat && !(b[12] == 0 && b[13] == 0 && b[14] == 0) { return howlFrameForbiddenDest(netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]})) } + translated := b[8] == 0xff && b[9] == 0xff && b[10] == 0 && b[11] == 0 + for _, x := range b[:8] { + translated = translated && x == 0 + } + if translated { + return howlFrameForbiddenDest(netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]})) + } } if !a.IsGlobalUnicast() { return true diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index 19bd79bd..ab9878fe 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -524,6 +524,8 @@ function howlFrameIsSpecial(b) { if (b[0] === 0x20 && b[1] === 0x02 && howlFrameIsSpecial(mapped(b.slice(2, 6)))) return true; var compat = b.slice(0, 12).every(function (x) { return x === 0; }); if (compat && !(b[12] === 0 && b[13] === 0 && b[14] === 0)) return howlFrameIsSpecial(mapped(b.slice(12, 16))); + var translated = b.slice(0, 8).every(function (x) { return x === 0; }) && b[8] === 255 && b[9] === 255 && b[10] === 0 && b[11] === 0; + if (translated) return howlFrameIsSpecial(mapped(b.slice(12, 16))); for (var i = 0; i < howlFrameSpecialPrefixes.length; i++) { var p = howlFrameSpecialPrefixes[i][0], bits = howlFrameSpecialPrefixes[i][1], ok = true; for (var k = 0; k < 16 && ok && bits > 0; k++, bits -= 8) { diff --git a/internal/capability/destinations.go b/internal/capability/destinations.go index 1c2daaed..27cb0ed7 100644 --- a/internal/capability/destinations.go +++ b/internal/capability/destinations.go @@ -9,14 +9,15 @@ import ( // not reach (#172, #177). Generated Go and JavaScript render this same table, // so the three targets cannot drift. IPv4 entries also match IPv4-mapped // IPv6; IPv4 embedded in IPv4-compatible (::a.b.c.d) and 6to4 (2002::/16) -// addresses is decoded and checked against the IPv4 rows. NAT64 and Teredo +// addresses, and IPv4-translated ::ffff:0:a.b.c.d, is decoded and checked +// against the IPv4 rows. NAT64 and Teredo // are refused outright because the embedded address is attacker-chosen. var SpecialPrefixes = []string{ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "::/128", "::1/128", "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001::/32", - "2001:db8::/32", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8", + "2001:db8::/32", "3fff::/20", "5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8", } var specialPrefixes = func() []netip.Prefix { @@ -44,6 +45,9 @@ func ForbiddenDestination(a netip.Addr) bool { if compat && !(b[12] == 0 && b[13] == 0 && b[14] == 0) { // ::a.b.c.d return ForbiddenDestination(netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]})) } + if translatedIPv4(b) { // ::ffff:0:a.b.c.d (::ffff:0:0/96) + return ForbiddenDestination(netip.AddrFrom4([4]byte{b[12], b[13], b[14], b[15]})) + } } if !a.IsGlobalUnicast() { return true @@ -56,6 +60,16 @@ func ForbiddenDestination(a netip.Addr) bool { return false } +// translatedIPv4 reports the IPv4-translated form ::ffff:0:a.b.c.d (RFC 2765). +func translatedIPv4(b [16]byte) bool { + for _, x := range b[:8] { + if x != 0 { + return false + } + } + return b[8] == 0xff && b[9] == 0xff && b[10] == 0 && b[11] == 0 +} + // optOutHost reports an explicit opt-out destination: an IP literal or localhost. func optOutHost(u *url.URL) (string, bool) { t, err := urlTarget(u) diff --git a/internal/nettest/security.go b/internal/nettest/security.go index 6603a8a5..4c56a768 100644 --- a/internal/nettest/security.go +++ b/internal/nettest/security.go @@ -259,10 +259,12 @@ var BlockedAddresses = []string{ "0.1.2.3", "198.18.0.1", "198.19.255.255", "192.0.0.1", "240.0.0.1", "255.255.255.255", "fec0::1", "::7f00:1", "::a00:5", "2002:7f00:1::1", "2002:a00:5::1", "2002:6464:64c8::1", "2001:0:4136:e378::1", "100::1", "2001:db8::1", "::", + "3fff::1", "3fff:fff::1", "5f00::1", "5f00:ffff::1", "::ffff:0:7f00:1", "::ffff:0:a00:5", "::ffff:0:a9fe:a9fe", } // PublicAddresses must stay reachable for a hostname grant on every target. var PublicAddresses = []string{ "8.8.8.8", "172.15.0.1", "172.32.0.1", "192.169.1.1", "100.63.255.255", "100.128.0.1", "198.17.255.255", "198.20.0.1", "2001:4860::1", "2606:4700::1", "2002:808:808::1", "::808:808", + "::ffff:0:808:808", "3fff:1000::1", "5f01::1", } From 674401e5dbd8ef5ab5704d77fadc78ccaeea9f14 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:23:42 -0400 Subject: [PATCH 4/7] capability/gogen/js: pin localhost opt-out to loopback; Node pins and vets every resolved address (#177) --- internal/backend/gogen/gogen.go | 9 ++++- internal/backend/javascript/javascript.go | 26 +++++++++++---- .../javascript/network_security_test.go | 33 ++++++++++++++++++- internal/capability/network.go | 23 +++++++++++-- internal/nettest/security.go | 24 ++++++++++++++ 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index bcfbed40..29e33fe2 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -900,7 +900,14 @@ var howlFrameFetchClient = &http.Client{ return howlFrameFetchDenied{} } a := ap.Addr().Unmap() - if a.IsMulticast() || litErr != nil && !strings.EqualFold(host, "localhost") && howlFrameForbiddenDest(a) { + if a.IsMulticast() { + return howlFrameFetchDenied{} + } + if litErr != nil && strings.EqualFold(host, "localhost") { + if !a.IsLoopback() { + return howlFrameFetchDenied{} + } + } else if litErr != nil && howlFrameForbiddenDest(a) { return howlFrameFetchDenied{} } return nil diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index ab9878fe..a2bf9055 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -537,6 +537,12 @@ function howlFrameIsSpecial(b) { return false; } +function howlFrameIsLoopback(b) { + if (!b) return false; + var v4 = b.slice(0, 12).join(",") === "0,0,0,0,0,0,0,0,0,0,255,255"; + return v4 && b[12] === 127 || b.slice(0, 15).every(function (x) { return x === 0; }) && b[15] === 1; +} + function howlFrameForbiddenAddress(address, explicit) { var b = howlFrameAddrBytes(address); if (!b) return true; @@ -553,11 +559,14 @@ async function howlFrameFetchResolvedOk(u) { if (typeof require !== "function") return true; var addrs = await require("dns").promises.lookup(host, { all: true }); if (!addrs.length) return false; + var localhost = host === "localhost"; for (var i = 0; i < addrs.length; i++) { - if (howlFrameForbiddenAddress(addrs[i].address, host === "localhost")) return false; + if (howlFrameForbiddenAddress(addrs[i].address, localhost)) return false; + // The localhost opt-out reaches loopback only (127/8, ::1). + if (localhost && !howlFrameIsLoopback(howlFrameAddrBytes(addrs[i].address))) return false; } - // Pin this request to a vetted address; do not resolve again at connect time. - u.howlFrameAddress = addrs[0]; + // Pin this request to the full vetted set; never resolve again at connect time. + u.howlFrameAddresses = addrs.map(function (a) { return { address: a.address, family: a.family }; }); return true; } @@ -567,9 +576,14 @@ function howlFrameFetchRequest(u, init) { // NODE_USE_ENV_PROXY. agent:false also prevents pooled connections bypassing lookup. return new Promise(function (resolve, reject) { var options = { method: init.method, headers: init.headers, agent: false }; - if (u.howlFrameAddress) options.lookup = function (host, opts, cb) { - var a = u.howlFrameAddress; - cb(null, opts && opts.all ? [{ address: a.address, family: a.family }] : a.address, a.family); + if (u.howlFrameAddresses) options.lookup = function (host, opts, cb) { + if (typeof opts === "function") { cb = opts; opts = {}; } + var all = u.howlFrameAddresses.filter(function (a) { + return !opts || !opts.family || opts.family === a.family || opts.family === "IPv" + a.family; + }); + if (!all.length) return cb(new Error("CAPABILITY_DENIED: capability denied: network")); + if (opts && opts.all) return cb(null, all.map(function (a) { return { address: a.address, family: a.family }; })); + cb(null, all[0].address, all[0].family); }; var req = require(u.protocol === "https:" ? "https" : "http").request(u, options, function (res) { var chunks = []; diff --git a/internal/backend/javascript/network_security_test.go b/internal/backend/javascript/network_security_test.go index cc3c406e..0c0facac 100644 --- a/internal/backend/javascript/network_security_test.go +++ b/internal/backend/javascript/network_security_test.go @@ -84,7 +84,8 @@ func TestJSPrivateRanges(t *testing.T) { func TestJSRedirectToOptOut(t *testing.T) { securityJS(t, jsServers+` const dns=require("dns"); const original=dns.promises.lookup; - dns.promises.lookup=async()=>[{address:"93.184.216.34",family:4}]; + // localhost now has to resolve to loopback (#177); every other name is public. + dns.promises.lookup=async h=>[{address:h==="localhost"?"127.0.0.1":"93.184.216.34",family:4}]; let seen=[]; howlFrameFetchRequest=async (u)=>{seen.push(u.href); const to=u.searchParams.get("to"); return to ? {status:302,headers:{get:()=>to}} : {status:200,headers:{get:()=>null},text:async()=>"ok"};}; @@ -284,3 +285,33 @@ func TestJSFetchCompressedResponse(t *testing.T) { } `) } + +// #177: localhost reaches loopback only, and Node pins every vetted address. +func TestJSLocalhostLoopbackAndPinAll(t *testing.T) { + securityJS(t, ` + const dns=require("dns"); const original=dns.promises.lookup; + try { + for (const set of [["10.0.0.5"],["8.8.8.8"],["::ffff:10.0.0.5"],["fd00::1"],["127.0.0.1","10.0.0.5"],["::1","93.184.216.34"]]) { + dns.promises.lookup=async()=>set.map(a=>({address:a,family:a.includes(":")?6:4})); + assert.equal(await howlFrameFetchResolvedOk(new URL("http://localhost/")),false,set.join(",")); + } + for (const set of [["127.0.0.1"],["127.0.0.1","::1"],["127.9.9.9"]]) { + dns.promises.lookup=async()=>set.map(a=>({address:a,family:a.includes(":")?6:4})); + assert.equal(await howlFrameFetchResolvedOk(new URL("http://localhost/")),true,set.join(",")); + } + dns.promises.lookup=async()=>[{address:"93.184.216.34",family:4},{address:"10.0.0.5",family:4}]; + assert.equal(await howlFrameFetchResolvedOk(new URL("http://api.example.com/")),false); + dns.promises.lookup=async()=>[{address:"93.184.216.34",family:4},{address:"2606:4700::1",family:6}]; + const u=new URL("http://api.example.com/"); + assert.equal(await howlFrameFetchResolvedOk(u),true); + const http=require("http"); const realRequest=http.request; let lookupFn; + http.request=(url,options)=>{lookupFn=options.lookup; throw new Error("stop");}; + try { await howlFrameFetchRequest(u,{method:"GET",headers:{}}); } catch(e) {} + http.request=realRequest; + const call=opts=>new Promise((res,rej)=>lookupFn("api.example.com",opts,(err,a,f)=>err?rej(err):res([a,f]))); + assert.deepEqual((await call({all:true}))[0],[{address:"93.184.216.34",family:4},{address:"2606:4700::1",family:6}]); + assert.deepEqual(await call({family:6}),["2606:4700::1",6]); + assert.deepEqual(await call({}),["93.184.216.34",4]); + } finally {dns.promises.lookup=original;} +`) +} diff --git a/internal/capability/network.go b/internal/capability/network.go index 02375aff..820b1cca 100644 --- a/internal/capability/network.go +++ b/internal/capability/network.go @@ -280,14 +280,31 @@ func forbiddenResolved(a netip.Addr) bool { // checkDial vets the resolved address a connection is about to use. A DNS // name may not land on a forbidden destination. Explicit literal and -// localhost grants opt out of private-address checks, but never multicast. +// localhost grants opt out of private-address checks, but never multicast; +// localhost must resolve to loopback. func checkDial(host string, literal bool, address string) error { ap, err := netip.ParseAddrPort(address) if err != nil { return &FetchDenied{Reason: "network: fetch dial address is invalid", Required: FetchGrantPrefix + host} } - if ap.Addr().IsMulticast() || !literal && !strings.EqualFold(host, "localhost") && forbiddenResolved(ap.Addr()) { - return &FetchDenied{Reason: "network: granted host resolved to a forbidden destination", Required: FetchGrantPrefix + canonicalIP(ap.Addr())} + a := ap.Addr().Unmap() + deny := func() error { + return &FetchDenied{Reason: "network: granted host resolved to a forbidden destination", Required: FetchGrantPrefix + canonicalIP(a)} + } + switch { + case a.IsMulticast(): + return deny() + case literal: + return nil + case strings.EqualFold(host, "localhost"): + // #177: the localhost opt-out reaches loopback only, never a private + // or public address that a hostile resolver maps localhost to. + if !a.IsLoopback() { + return deny() + } + return nil + case forbiddenResolved(a): + return deny() } return nil } diff --git a/internal/nettest/security.go b/internal/nettest/security.go index 4c56a768..bb384725 100644 --- a/internal/nettest/security.go +++ b/internal/nettest/security.go @@ -239,6 +239,30 @@ func Run(t *testing.T, client func(...string) *http.Client, lookup func(Lookup) }) } }) + t.Run("LocalhostLoopbackOnly", func(t *testing.T) { + for _, set := range [][]string{{"10.0.0.5"}, {"8.8.8.8"}, {"::ffff:10.0.0.5"}, {"fd00::1"}, {"169.254.169.254"}, {"127.0.0.1", "10.0.0.5"}, {"::1", "93.184.216.34"}} { + t.Run(strings.Join(set, ","), func(t *testing.T) { + restore := lookup(func(context.Context, string) ([]net.IPAddr, error) { + out := []net.IPAddr{} + for _, ip := range set { + out = append(out, net.IPAddr{IP: net.ParseIP(ip)}) + } + return out, nil + }) + defer restore() + c := client("network:fetch=localhost:80") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + conn, err := c.Transport.(*http.Transport).DialContext(ctx, "tcp", "localhost:80") + if conn != nil { + conn.Close() + } + if err == nil || !(strings.Contains(err.Error(), "network:") || strings.Contains(err.Error(), "capability denied")) { + t.Fatalf("localhost opt-out reached non-loopback %v: %v", set, err) + } + }) + } + }) t.Run("MulticastDial", func(t *testing.T) { for _, address := range []string{"224.0.0.1:80", "[ff02::1]:80"} { c := client("network:fetch=" + address) From 67b1a4c6c08605bec0ad0d2e544d31e297a30f08 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:25:05 -0400 Subject: [PATCH 5/7] capability/gogen/js: strip all but shared safe headers on origin change (Proxy-Authorization, custom auth); refuse userinfo redirect targets (#177) --- internal/backend/gogen/gogen.go | 18 ++++++- .../backend/gogen/network_security_test.go | 1 + internal/backend/javascript/javascript.go | 10 +++- .../javascript/network_security_test.go | 33 ++++++++++++ internal/capability/network.go | 26 +++++++++- internal/nettest/security.go | 52 +++++++++++++++++++ 6 files changed, 135 insertions(+), 5 deletions(-) diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index 29e33fe2..7ff73ba5 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -708,6 +708,10 @@ func specialPrefixGoSource() string { for _, p := range capability.SpecialPrefixes { fmt.Fprintf(&b, "\tnetip.MustParsePrefix(%q),\n", p) } + b.WriteString("}\n\nvar howlFrameRedirectSafeHeaders = []string{\n") + for _, h := range capability.RedirectSafeHeaders { + fmt.Fprintf(&b, "\t%q,\n", h) + } b.WriteString("}\n\n") b.WriteString(`// howlFrameForbiddenDest mirrors capability.ForbiddenDestination. func howlFrameForbiddenDest(a netip.Addr) bool { @@ -944,6 +948,9 @@ var howlFrameFetchClient = &http.Client{ if previous.Scheme == "https" && req.URL.Scheme == "http" { return howlFrameFetchDenied{} } + if req.URL.User != nil { + return howlFrameFetchDenied{} + } if host, opt := howlFrameOptOutHost(req.URL); opt { if start, _ := howlFrameOptOutHost(via[0].URL); start != host { return howlFrameFetchDenied{} @@ -954,8 +961,15 @@ var howlFrameFetchClient = &http.Client{ changedOrigin = changedOrigin || !howlFrameFetchSameOrigin(via[i-1].URL, via[i].URL) } if changedOrigin { - req.Header.Del("Authorization") - req.Header.Del("Cookie") + for name := range req.Header { + keep := false + for _, safe := range howlFrameRedirectSafeHeaders { + keep = keep || strings.EqualFold(name, safe) + } + if !keep { + delete(req.Header, name) + } + } } if len(via) > 10 || !howlFrameFetchAllowed(req.URL) { return howlFrameFetchDenied{} diff --git a/internal/backend/gogen/network_security_test.go b/internal/backend/gogen/network_security_test.go index 5446a93a..4ecd612b 100644 --- a/internal/backend/gogen/network_security_test.go +++ b/internal/backend/gogen/network_security_test.go @@ -47,6 +47,7 @@ func TestCodeLoadingEnv(t *testing.T) { } func TestSharedTables(t *testing.T) { if !reflect.DeepEqual(howlFrameCodeLoadingEnvNames,capability.CodeLoadingEnvNames)||!reflect.DeepEqual(howlFrameCodeLoadingEnvPrefixes,capability.CodeLoadingEnvPrefixes) {t.Fatal("env tables drifted")} + if !reflect.DeepEqual(howlFrameRedirectSafeHeaders,capability.RedirectSafeHeaders) {t.Fatal("redirect header table drifted")} got:=[]string{} for _,p:=range howlFrameSpecialPrefixes {got=append(got,p.String())} if !reflect.DeepEqual(got,capability.SpecialPrefixes) {t.Fatalf("prefix table drifted: %v",got)} diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index a2bf9055..7275571f 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -630,9 +630,10 @@ async function howlFrameFetch(url, method, body, headers) { if (previous.protocol === "https:" && u.protocol === "http:") howlFrameFetchDeny(); var optHost = u.hostname.replace(/^\[|\]$/g, ""); if ((/^\[.*\]$/.test(u.hostname) || /^\d+\.\d+\.\d+\.\d+$/.test(optHost) || optHost === "localhost") && u.hostname !== first.hostname) howlFrameFetchDeny(); + if (u.username !== "" || u.password !== "") howlFrameFetchDeny(); if (previous.origin !== u.origin) { Object.keys(init.headers || {}).forEach(function (name) { - if (/^(authorization|cookie)$/i.test(name)) delete init.headers[name]; + if (howlFrameRedirectSafeHeaders.indexOf(String(name).toLowerCase()) < 0) delete init.headers[name]; }); } if (!howlFrameFetchAllowed(u)) howlFrameFetchDeny(); @@ -1501,6 +1502,13 @@ func specialPrefixJSSource() string { } fmt.Fprintf(&b, " [[%s], %d], // %s\n", strings.Join(parts, ","), bits, raw) } + b.WriteString("];\n\nvar howlFrameRedirectSafeHeaders = [") + for i, h := range capability.RedirectSafeHeaders { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(strings.ToLower(h))) + } b.WriteString("];\n\n") return b.String() } diff --git a/internal/backend/javascript/network_security_test.go b/internal/backend/javascript/network_security_test.go index 0c0facac..ac9d81d1 100644 --- a/internal/backend/javascript/network_security_test.go +++ b/internal/backend/javascript/network_security_test.go @@ -186,10 +186,16 @@ func TestJSSharedTables(t *testing.T) { rows = append(rows, [2]any{ints, bits}) } special, _ := json.Marshal(rows) + var lower []string + for _, h := range capability.RedirectSafeHeaders { + lower = append(lower, strings.ToLower(h)) + } + headers, _ := json.Marshal(lower) securityJS(t, ` assert.deepEqual(howlFrameCodeLoadingEnvNames,`+string(names)+`); assert.deepEqual(howlFrameCodeLoadingEnvPrefixes,`+string(prefixes)+`); assert.deepEqual(howlFrameSpecialPrefixes,`+string(special)+`); + assert.deepEqual(howlFrameRedirectSafeHeaders,`+string(headers)+`); `) } func TestJSProxyEnvIgnored(t *testing.T) { @@ -315,3 +321,30 @@ func TestJSLocalhostLoopbackAndPinAll(t *testing.T) { } finally {dns.promises.lookup=original;} `) } + +// #177: only the shared safe headers cross an origin change; userinfo redirect targets are refused. +func TestJSCrossOriginHeadersAndUserinfo(t *testing.T) { + sensitive, _ := json.Marshal(nettest.SensitiveHeaders) + securityJS(t, jsServers+` + let seen, hits=0; + const dst=await listen((req,res)=>{hits++;seen=req.headers;res.end("ok");}); + const src=await listen((req,res)=>{res.writeHead(307,{location:url(dst)});res.end();}); + const same=await listen((req,res)=>{ if(req.url==="/") {res.writeHead(307,{location:"/next"});return res.end();} seen=req.headers;res.end("ok");}); + const headers={Accept:"text/plain"}; for (const h of `+string(sensitive)+`) headers[h]="secret"; + try { + grants(url(src),url(dst)); + assert.equal(await howlFrameFetch(url(src),"GET",undefined,Object.assign({},headers)),"ok"); + assert.equal(hits,1); + for (const [k,v] of Object.entries(seen)) assert.ok(!String(v).includes("secret"),k+" crossed origin"); + assert.equal(seen.accept,"text/plain"); + grants(url(same)); + assert.equal(await howlFrameFetch(url(same),"GET",undefined,Object.assign({},headers)),"ok"); + assert.equal(seen["x-api-key"],"secret","same-origin custom header dropped"); + for (const target of ["http://user:pw@127.0.0.1:"+dst.address().port+"/","http://user@127.0.0.1:"+dst.address().port+"/"]) { + const r=await listen((req,res)=>{res.writeHead(302,{location:target});res.end();}); + hits=0; grants(url(r),url(dst)); + await deny(()=>howlFrameFetch(url(r),"GET")); assert.equal(hits,0,target); r.close(); + } + } finally {src.close();dst.close();same.close();} +`) +} diff --git a/internal/capability/network.go b/internal/capability/network.go index 820b1cca..23030e5f 100644 --- a/internal/capability/network.go +++ b/internal/capability/network.go @@ -378,6 +378,9 @@ func FetchClient(g Grants, onHop func(u *url.URL, grant string, err error)) *htt if previous.Scheme == "https" && req.URL.Scheme == "http" { return &FetchDenied{Reason: "network: HTTPS redirect downgrade refused"} } + if req.URL.User != nil { + return &FetchDenied{Reason: "network: redirect target with userinfo refused", Required: FetchGrantPrefix + ""} + } if redirectToOptOut(via[0].URL, req.URL) { required := FetchGrantPrefix + "" if t, err := urlTarget(req.URL); err == nil { @@ -391,8 +394,7 @@ func FetchClient(g Grants, onHop func(u *url.URL, grant string, err error)) *htt changedOrigin = changedOrigin || !sameFetchOrigin(via[i-1].URL, via[i].URL) } if changedOrigin { - req.Header.Del("Authorization") - req.Header.Del("Cookie") + StripCrossOriginHeaders(req.Header) } grant, err := g.AuthorizeFetch(req.URL) if onHop != nil { @@ -462,3 +464,23 @@ func FetchPrivateOptOut(grant string) string { } return "" } + +// RedirectSafeHeaders are the only request headers that survive a redirect +// that changes origin (scheme, host or effective port). Everything else, +// including Authorization, Proxy-Authorization, Cookie and custom auth +// headers such as X-Api-Key, is dropped (#172, #177). Generated Go and +// JavaScript render this same list. +var RedirectSafeHeaders = []string{"Accept", "Accept-Encoding", "Accept-Language", "Content-Type", "Content-Length", "Content-Encoding", "User-Agent"} + +// StripCrossOriginHeaders deletes every header not in RedirectSafeHeaders. +func StripCrossOriginHeaders(h http.Header) { + for name := range h { + keep := false + for _, safe := range RedirectSafeHeaders { + keep = keep || strings.EqualFold(name, safe) + } + if !keep { + delete(h, name) + } + } +} diff --git a/internal/nettest/security.go b/internal/nettest/security.go index bb384725..2c1a5f44 100644 --- a/internal/nettest/security.go +++ b/internal/nettest/security.go @@ -174,15 +174,64 @@ func Run(t *testing.T, client func(...string) *http.Client, lookup func(Lookup) } }) + t.Run("CrossOriginHeaders", func(t *testing.T) { + seen := http.Header{} + var hits atomic.Int64 + dst := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + seen = r.Header.Clone() + })) + defer dst.Close() + src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, dst.URL, 307) })) + defer src.Close() + c := client(grant(src.URL), grant(dst.URL)) + req, _ := http.NewRequest("GET", src.URL, nil) + for _, h := range SensitiveHeaders { + req.Header.Set(h, "secret") + } + req.Header.Set("Accept", "text/plain") + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if hits.Load() != 1 { + t.Fatalf("hits=%d", hits.Load()) + } + for name, v := range seen { + for _, x := range v { + if strings.Contains(x, "secret") { + t.Errorf("%s crossed origin", name) + } + } + } + if seen.Get("Accept") != "text/plain" { + t.Errorf("safe Accept header dropped: %v", seen) + } + }) + t.Run("RedirectUserinfo", func(t *testing.T) { + c := client("network:fetch=api.example.com", "network:fetch=other.example.com") + first, _ := http.NewRequest("GET", "http://api.example.com/", nil) + for _, to := range []string{"http://user:pw@api.example.com/", "http://user@other.example.com/", "http://:pw@api.example.com/x"} { + next, _ := http.NewRequest("GET", to, nil) + if err := c.CheckRedirect(next, []*http.Request{first}); err == nil { + t.Errorf("redirect to %s allowed", to) + } + } + }) t.Run("CredentialsSameOrigin", func(t *testing.T) { c := client("network:fetch=example.test") first, _ := http.NewRequest("GET", "http://EXAMPLE.test/", nil) next, _ := http.NewRequest("GET", "http://example.test:80/next", nil) next.Header.Set("Authorization", "Bearer secret") next.Header.Set("Cookie", "session=secret") + next.Header.Set("X-Api-Key", "secret") if err := c.CheckRedirect(next, []*http.Request{first}); err != nil { t.Fatal(err) } + if next.Header.Get("X-Api-Key") != "secret" { + t.Fatal("same effective origin lost custom header") + } if next.Header.Get("Authorization") != "Bearer secret" || next.Header.Get("Cookie") != "session=secret" { t.Fatal("same effective origin lost credentials") } @@ -274,6 +323,9 @@ func Run(t *testing.T, client func(...string) *http.Client, lookup func(Lookup) }) } +// SensitiveHeaders must never cross a change of origin on any target. +var SensitiveHeaders = []string{"Authorization", "Proxy-Authorization", "Cookie", "X-Api-Key", "X-Auth-Token", "Private-Token", "X-Amz-Security-Token", "Api-Key"} + // BlockedAddresses must be refused for a hostname grant on every target. var BlockedAddresses = []string{ "127.1.2.3", "::1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", From dd0caff2d8d94adfe3283450a5265f631cf804b3 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:26:25 -0400 Subject: [PATCH 6/7] js: treat [::ffff:a.b.c.d] as a.b.c.d like core and generated Go; cross-target mapped-literal cases (#177) --- internal/backend/javascript/javascript.go | 20 +++++++++-- .../javascript/network_security_test.go | 16 +++++++++ internal/nettest/security.go | 36 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index 7275571f..eea468e1 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -447,6 +447,20 @@ func fetchJSHelper() string { // Mirrors capability.Grants.AuthorizeFetch: exact host (WHATWG-canonical, // so IP literals compare in canonical form) and port; an omitted grant port // means the scheme default. The coarse network grant never fetches. +// howlFrameCanonHost mirrors capability's canonicalIP: an IPv4-mapped IPv6 +// literal ([::ffff:a.b.c.d]) is the same host as a.b.c.d (#177). +function howlFrameCanonHost(hostname) { + var h = String(hostname).toLowerCase(); + if (h.charAt(0) !== "[") return h; + var b = howlFrameAddrBytes(h); + if (b && b.slice(0, 12).join(",") === "0,0,0,0,0,0,0,0,0,0,255,255") return b.slice(12).join("."); + return h; +} + +function howlFrameFetchOrigin(u) { + return u.protocol + "//" + howlFrameCanonHost(u.hostname) + ":" + (u.port || (u.protocol === "https:" ? "443" : "80")); +} + function howlFrameFetchAllowed(u) { if (!u || (u.protocol !== "http:" && u.protocol !== "https:") || !u.hostname) return false; if (u.hostname.charAt(u.hostname.length - 1) === ".") return false; @@ -461,7 +475,7 @@ function howlFrameFetchAllowed(u) { try { g = new URL("http://" + spec); } catch (e) { continue; } var writtenHost = spec.charAt(0) === "[" ? spec.slice(0, spec.indexOf("]") + 1) : spec.split(":")[0].toLowerCase(); if (writtenHost.charAt(0) !== "[" && /(^|\.)[0-9][^.]*$/.test(writtenHost) && writtenHost !== g.hostname) continue; - if (g.hostname !== u.hostname) continue; + if (howlFrameCanonHost(g.hostname) !== howlFrameCanonHost(u.hostname)) continue; var gport = /:\d+$/.test(spec) ? spec.slice(spec.lastIndexOf(":") + 1) : def; if (String(Number(gport)) === gport && gport === port) return true; } @@ -629,9 +643,9 @@ async function howlFrameFetch(url, method, body, headers) { try { u = howlFrameFetchURL(loc, u); } catch (e) { howlFrameFetchDeny(); } if (previous.protocol === "https:" && u.protocol === "http:") howlFrameFetchDeny(); var optHost = u.hostname.replace(/^\[|\]$/g, ""); - if ((/^\[.*\]$/.test(u.hostname) || /^\d+\.\d+\.\d+\.\d+$/.test(optHost) || optHost === "localhost") && u.hostname !== first.hostname) howlFrameFetchDeny(); + if ((/^\[.*\]$/.test(u.hostname) || /^\d+\.\d+\.\d+\.\d+$/.test(optHost) || optHost === "localhost") && howlFrameCanonHost(u.hostname) !== howlFrameCanonHost(first.hostname)) howlFrameFetchDeny(); if (u.username !== "" || u.password !== "") howlFrameFetchDeny(); - if (previous.origin !== u.origin) { + if (howlFrameFetchOrigin(previous) !== howlFrameFetchOrigin(u)) { Object.keys(init.headers || {}).forEach(function (name) { if (howlFrameRedirectSafeHeaders.indexOf(String(name).toLowerCase()) < 0) delete init.headers[name]; }); diff --git a/internal/backend/javascript/network_security_test.go b/internal/backend/javascript/network_security_test.go index ac9d81d1..cc277f0c 100644 --- a/internal/backend/javascript/network_security_test.go +++ b/internal/backend/javascript/network_security_test.go @@ -348,3 +348,19 @@ func TestJSCrossOriginHeadersAndUserinfo(t *testing.T) { } finally {src.close();dst.close();same.close();} `) } + +// #177: Node agrees with core on [::ffff:a.b.c.d]. +func TestJSMappedLiteralGrants(t *testing.T) { + cases, _ := json.Marshal(nettest.MappedGrantCases) + securityJS(t, ` + for (const tc of `+string(cases)+`) { + process.env.HOWLFRAME_ALLOW_CAPS="network:fetch="+tc.Grant; + assert.equal(howlFrameFetchAllowed(howlFrameFetchURL(tc.URL)),tc.Allowed,tc.Grant+" "+tc.URL); + } + let seen=[]; + howlFrameFetchRequest=async (u,init)=>{seen.push(init.headers["X-Api-Key"]); return u.pathname==="/" ? {status:302,headers:{get:()=>"http://[::ffff:1.2.3.4]/next"}} : {status:200,headers:{get:()=>null},text:async()=>"ok"};}; + process.env.HOWLFRAME_ALLOW_CAPS="network:fetch=1.2.3.4"; + assert.equal(await howlFrameFetch("http://1.2.3.4/","GET",undefined,{"X-Api-Key":"secret"}),"ok"); + assert.deepEqual(seen,["secret","secret"]); +`) +} diff --git a/internal/nettest/security.go b/internal/nettest/security.go index 2c1a5f44..50d55b68 100644 --- a/internal/nettest/security.go +++ b/internal/nettest/security.go @@ -312,6 +312,25 @@ func Run(t *testing.T, client func(...string) *http.Client, lookup func(Lookup) }) } }) + t.Run("MappedLiteralGrants", func(t *testing.T) { + for _, tc := range MappedGrantCases { + c := client("network:fetch=" + tc.Grant) + req, _ := http.NewRequest("GET", tc.URL, nil) + first, _ := http.NewRequest("GET", tc.URL, nil) + // CheckRedirect re-authorizes the hop against the grant list. + err := c.CheckRedirect(req, []*http.Request{first}) + if (err == nil) != tc.Allowed { + t.Errorf("grant %s url %s: err=%v want allowed=%v", tc.Grant, tc.URL, err, tc.Allowed) + } + } + c := client("network:fetch=1.2.3.4") + first, _ := http.NewRequest("GET", "http://1.2.3.4/", nil) + next, _ := http.NewRequest("GET", "http://[::ffff:1.2.3.4]/next", nil) + next.Header.Set("X-Api-Key", "secret") + if err := c.CheckRedirect(next, []*http.Request{first}); err != nil || next.Header.Get("X-Api-Key") != "secret" { + t.Errorf("mapped spelling of the same literal treated as another origin: %v %v", err, next.Header) + } + }) t.Run("MulticastDial", func(t *testing.T) { for _, address := range []string{"224.0.0.1:80", "[ff02::1]:80"} { c := client("network:fetch=" + address) @@ -344,3 +363,20 @@ var PublicAddresses = []string{ "198.17.255.255", "198.20.0.1", "2001:4860::1", "2606:4700::1", "2002:808:808::1", "::808:808", "::ffff:0:808:808", "3fff:1000::1", "5f01::1", } + +// MappedGrantCases pin how every target treats IPv4-mapped IPv6 literals +// (#177): [::ffff:a.b.c.d] is the same host as a.b.c.d, in either direction. +var MappedGrantCases = []struct { + Grant, URL string + Allowed bool +}{ + {"1.2.3.4", "http://[::ffff:1.2.3.4]/", true}, + {"[::ffff:1.2.3.4]", "http://1.2.3.4/", true}, + {"[::ffff:1.2.3.4]", "http://[::ffff:102:304]/", true}, + {"1.2.3.4:8080", "http://[::ffff:1.2.3.4]:8080/", true}, + {"1.2.3.4", "http://[::ffff:1.2.3.5]/", false}, + {"1.2.3.4", "http://[::ffff:1.2.3.4]:8080/", false}, + {"[::1.2.3.4]", "http://1.2.3.4/", false}, + {"1.2.3.4", "http://[::1.2.3.4]/", false}, + {"1.2.3.4", "http://[64:ff9b::102:304]/", false}, +} From 6da850e4d919adad43ce15ead2544d7e69ddc2c9 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 13 Oct 2026 10:27:14 -0400 Subject: [PATCH 7/7] docs: changelog, authority references and journal for #177 and #175 --- change_log.md | 1 + ..._network_env_followups_adapter_deadline.md | 22 +++++++++++++++ docs/reference/network_authority.md | 28 +++++++++++++------ docs/reference/process_authority.md | 26 +++++++++++------ 4 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 docs/journals/2026-10-13_network_env_followups_adapter_deadline.md diff --git a/change_log.md b/change_log.md index 090fa961..42718fb8 100644 --- a/change_log.md +++ b/change_log.md @@ -3,6 +3,7 @@ ## Unreleased ### Breaking +* Network and env hardening follow-ups (#177) and runner-owned adapter deadline (#175). Breaking: (1) `process:env=` now refuses one single-sourced table (`capability.CodeLoadingEnvNames` + `CodeLoadingEnvPrefixes`, rendered into generated Go and JavaScript) that adds `PATH`, `HOME`, `XDG_CONFIG_HOME`/`XDG_CONFIG_DIRS`/`XDG_DATA_HOME`/`XDG_DATA_DIRS`, every `GIT_*`, `BASH_FUNC_*` and `LUA_*` name, `SHELLOPTS`, `BASHOPTS`, `PS4`, `PROMPT_COMMAND`, `IFS`, `CDPATH`, `GLOBIGNORE`, `ZDOTDIR`, `PYTHONHOME`, `PYTHONUSERBASE`, `PYTHONINSPECT`, `PYTHONWARNINGS`, `PYTHONBREAKPOINT`, `PYTHONEXECUTABLE`, `NODE_EXTRA_CA_CERTS`, `NODE_TLS_REJECT_UNAUTHORIZED`, `NODE_REPL_EXTERNAL_MODULE`, `PERLLIB`, `PERL5DB`, `GOFLAGS`, `GODEBUG`, `GOROOT`, `GOPATH`, `GOTOOLCHAIN`, `GOENV`, `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`, `CLASSPATH`, `GCONV_PATH`, `LOCPATH`, `NLSPATH`, `HOSTALIASES`, `RESOLV_HOST_CONF`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `CURL_CA_BUNDLE` and `REQUESTS_CA_BUNDLE`; grants such as `process:env=HOME` or `process:env=PATH` that used to parse now fail at parse time (interpreter, `-run-bc`) and are skipped by generated Go/JS. (2) On a redirect that changes scheme, host or effective port, every request header except `capability.RedirectSafeHeaders` (`Accept`, `Accept-Encoding`, `Accept-Language`, `Content-Type`, `Content-Length`, `Content-Encoding`, `User-Agent`) is dropped, including `Proxy-Authorization`, `Referer` and custom auth headers; previously only `Authorization` and `Cookie` were. (3) Redirect targets carrying userinfo are refused on all three targets. (4) The `localhost` opt-out now requires every resolved address to be loopback (127/8, `::1`). (5) `3fff::/20` and `5f00::/16` join `capability.SpecialPrefixes`, and IPv4-translated `::ffff:0:a.b.c.d` is decoded and checked as IPv4. (6) Node vets and pins the whole resolved address set rather than the first entry, and treats `[::ffff:a.b.c.d]` as `a.b.c.d` like core and generated Go (a grant for one now matches the other; same-literal redirects keep headers). (7) #175: `vm.ExecutionPolicy.AdapterDeadline` lets the runner raise the model-adapter wall-clock budget from the 1s default up to `vm.MaxAdapterDeadline` (30s); larger values are clamped, a shorter `Deadline` still wins, and `vm.AdapterDeadlineFlag` / `vm.ParseAdapterDeadline` give runners a `-adapter-deadline` flag that rejects zero, negative and over-cap values. Candidates cannot set it, and artifact runs are unaffected. New parity tests check that the generated Go and JS tables equal the capability tables, and the blocked/public address lists now also run against generated Go. Pins, freeze, tip-lock, arity and target parity are unchanged. Journal: [Network/env follow-ups and adapter deadline](docs/journals/2026-10-13_network_env_followups_adapter_deadline.md). * Network destination hardening (#171, #172). On the capability client, generated Go and Node JavaScript, hostname grants now refuse DNS results in loopback (127/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), ULA (fc00::/7), NAT64 (64:ff9b::/96), link-local and unspecified ranges at connection time. Explicit IP-literal grants and the exact name `localhost` opt out; allowed bytecode receipt effects record `private_destination_opt_out: "explicit IP-literal or localhost grant"`. Multicast (224/4, ff00::/8) is always refused, including explicit literals. Review follow-up (#176, part of #177): the blocked set is one shared table (`capability.SpecialPrefixes`) rendered into generated Go and JavaScript. It adds CGNAT 100.64/10 (including 100.100.100.200 metadata), 0/8, 192.0.0/24, 198.18/15, 240/4 (including broadcast), the documentation ranges, NAT64 64:ff9b:1::/48, Teredo 2001::/32, 100::/64, fec0::/10 and any non-global-unicast address. IPv4 inside IPv4-compatible (::a.b.c.d) and 6to4 (2002::/16) addresses is decoded and checked. A redirect may land on an IP-literal or `localhost` destination only when the request started on that same host: a hostname grant can no longer bounce into a granted private IP or localhost, and localhost cannot bounce into 127.0.0.1. HTTPS-to-HTTP redirects are refused even when both destinations are granted; Authorization and Cookie are stripped after any host, port or scheme change for the rest of the redirect chain. Ten-hop redirect caps and ignored HTTP_PROXY/HTTPS_PROXY are regression-tested on core, generated Go and JS; Node uses native HTTP requests with the checked DNS address pinned and no proxy agent, including under Node 24's NODE_USE_ENV_PROXY. JavaScript previously accepted short IPv4 grants such as `network:fetch=127.1` through WHATWG canonicalization; it now refuses ambiguous numeric grants and URLs like Go. Trailing-dot names report an invalid hostname rather than an ambiguous numeric host. Browser JavaScript retains host/redirect checks but cannot inspect DNS results. Migration: use an explicit IP-literal or `localhost:` grant only when private access is intended; granting a route its own localhost port also grants access to its `/admin`. `process:env=` now also refuses PYTHONPATH, PYTHONSTARTUP, NODE_OPTIONS, NODE_PATH, BASH_ENV, ENV, PERL5LIB, PERL5OPT, RUBYOPT and RUBYLIB, case-insensitively, alongside LD_*/DYLD_*; generated children omit them even if a raw grant bypasses parsing. `apps/webhook_relay` still reads UPSTREAM_URL at runtime but its route remains pinned to 127.0.0.1:8099; changing the environment does not expand its authority. No production HFIR flip; #90 stays Partial; pins, freeze tests and target exclusions unchanged. Reference: `docs/reference/network_authority.md`; evidence: `docs/journals/2026-10-13_network_destination_hardening.md`. * HFIR model adapter resource bounds (#169, #170): shared closed scalar `const`/`binary` subexpressions now lower once into private functions instead of exponentially expanded fragments; effects, variable reads and allocating expressions retain their evaluation sites and order. Candidates exceeding 2048 dependency levels, 65536 expanded nodes or 4194304 aggregate cached-fragment nodes fail closed with `HFIR_TRANSPORT_TOO_DEEP` / `HFIR_TRANSPORT_TOO_LARGE`, including callers that construct `Candidate` directly. Breaking for adapter consumers: in-memory programs returned by `CompileCandidate` now have a maximum one-second wall-clock budget (a shorter runner deadline still applies); a sleep beyond the remaining budget is refused immediately with `HFIR_DEADLINE_EXCEEDED`, which `try_let` cannot catch. Receipt paths report both the effective budget and typed failure. The adapter constraint is runner-owned and ephemeral; HFBC serialization remains unchanged, and artifact runners still select their execution policy. Explicit-deadline standalone VM sleeps also refuse durations beyond their remaining budget with the existing `LIMIT_EXCEEDED` code. Production HFIR compilation, #90 (Partial), target fences, SHA-256 pins, freeze tests and the Assurance tip-lock remain unchanged. Journal: [HFIR adapter resource blockers](docs/journals/2026-10-13_hfir_adapter_resource_blockers.md). * HFIR model adapter (#88) now covers the full `-compile-hfir-bc` language. `hfir-model-adapter/v1` accepts every node kind `LowerToBytecode` lowers from source (`hfir.AdapterNodeKinds`): `defun`/`param`/`call`/`return`, `while`, `for`, `try`/`catch`, `read_file`, `write_file`, `mkdir`, `exec`, `fetch`, `parse_json`, `is_nil`, `map_keys`, `html_escape`, `attr_escape`, `regex_match`, `time_now`, `sleep` (transport role `duration`), `cli_args`, `req_query`/`req_header`/`req_path`, and `(map_get key)` via a `target` edge, on top of the Phase-1 kinds. `for`/`while` control edges are derived from validated roles like `if`; the schema still has no control-edge field. Kinds the source lowerer does not lower (`spawn`, `spawn_agent`, `grants`, `llm_generate`, `http_server`/`res*`, store/db forms, `read_line`, `http_request`, `match`, ...) still fail with `HFIR_TRANSPORT_KIND`. No new opcode, capability, grant, lowering or language form; effects reached through the adapter are checked by the same run-time grants and fail closed without them. Breaking for adapter clients: candidate bounds rise to 4096 nodes, 64 inputs per node and 1 MiB per transport (from 128 / 32 / 64 KiB); the transport type check now types BOOL consts as bool, `to_int`/`to_float` as numbers and `str_split` as a list, and accepts string `+` string, so a candidate mixing a bool into arithmetic, or a `sleep` whose duration is a known non-number, is now rejected with `HFIR_TRANSPORT_TYPE`; a `return` outside a `defun` body is rejected with `HFIR_TRANSPORT_RETURN` unless it is a straight-line main-level statement (program/sequence/let body or if branch), which source already allows; cycles are rejected (`HFIR_TRANSPORT_CYCLE`) before type inference; non-const nodes never carry `literal_kind`. New experimental flag `-hfir-adapter-roundtrip` (with `-compile-hfir-bc`) sends the source graph through the adapter (encode, strict decode, `CompileCandidate`). `TestModelAdapterCoversArityTable` requires every one of the 386 corpus programs `-compile-hfir-bc` lowers to round-trip byte for byte, and every arity-table form to be covered (126 of 159) or carry an exemption with a rejected sample (33) that the test checks stays outside `-compile-hfir-bc`. The prod-flip criteria's model-adapter row is updated; the promote-blocker fence, production `-compile-bc`, #90 (Partial) and the tip-lock are unchanged. Pinned token benchmarks unchanged. diff --git a/docs/journals/2026-10-13_network_env_followups_adapter_deadline.md b/docs/journals/2026-10-13_network_env_followups_adapter_deadline.md new file mode 100644 index 00000000..aa76b77e --- /dev/null +++ b/docs/journals/2026-10-13_network_env_followups_adapter_deadline.md @@ -0,0 +1,22 @@ +# Network/env follow-ups and runner-owned adapter deadline (#177, #175) + +Consumer-driven findings from the #176 reviews (Lain, Motoko) and the #173 +review (Motoko). Each item has a negative test that was mutation-checked: +the fix was reverted or weakened locally and the named test failed. + +| Finding | Fix | Negative test | +| --- | --- | --- | +| env refusal list missed `GIT_*`, `GCONV_PATH`, `PYTHONHOME`, `PERL5DB`, `JAVA_TOOL_OPTIONS`, `SHELLOPTS`/`BASHOPTS`/`PS4`, `HOME`/`XDG_CONFIG_HOME`, `PATH`, and was duplicated in three files | One table, `capability.CodeLoadingEnvNames` + `CodeLoadingEnvPrefixes`, rendered into generated Go and JS | `nettest.EnvRefused`/`EnvAllowed` run on core (`TestEnvGrantRefusesCodeLoading`), generated Go (`TestCodeLoadingEnv`) and Node (`TestJSCodeLoadingEnv`); `TestSharedTables` / `TestJSSharedTables` compare the rendered tables | +| `Proxy-Authorization` and custom auth headers survived an origin change | Allow-list `capability.RedirectSafeHeaders`; every other header is dropped | `CrossOriginHeaders` (core, generated Go), `TestJSCrossOriginHeadersAndUserinfo` | +| userinfo in redirect targets was followed | Refused in `CheckRedirect` and the Node hop loop | `RedirectUserinfo`, `TestJSCrossOriginHeadersAndUserinfo` | +| Node pinned only the first resolved address | The whole vetted set is pinned; `lookup` honors `all` and `family` | `TestJSLocalhostLoopbackAndPinAll` | +| core and Node disagreed on `[::ffff:a.b.c.d]` | Node canonicalizes mapped literals like `canonicalIP` | `nettest.MappedGrantCases` on all three targets | +| `::ffff:0:0/96` not decoded; `3fff::/20`, `5f00::/16` missing | Decode and add rows | new `BlockedAddresses` / `PublicAddresses` rows; `TestAddressLists` now runs them on generated Go | +| `localhost` opt-out could reach any address | Every resolved address must be loopback | `LocalhostLoopbackOnly`, `TestJSLocalhostLoopbackAndPinAll` | +| #175: adapter budget fixed at 1s | `ExecutionPolicy.AdapterDeadline`, clamped at 30s; `AdapterDeadlineFlag` rejects over-cap values | `TestAdapterDeadlineRunnerOwned`, `TestAdapterDeadlineFlag`, `TestAdapterDeadlineIgnoredForArtifacts` | + +Choice: the env list stays a deny-list (single-sourced) rather than an +allow-list, because existing grants for ordinary variables (`LANG`, `TZ`, +app tokens) must keep working; `HOME` and `PATH` are now refused, which is +the one consumer-visible break. Exec children are already granted by +absolute path and start with an empty environment. diff --git a/docs/reference/network_authority.md b/docs/reference/network_authority.md index fea92eaf..af28d3b6 100644 --- a/docs/reference/network_authority.md +++ b/docs/reference/network_authority.md @@ -47,24 +47,34 @@ capability at all: they only touch the request the server already accepted. range in the shared table `capability.SpecialPrefixes`: CGNAT (`100.64/10`, which includes the `100.100.100.200` metadata address), `0/8`, `192.0.0/24`, `198.18/15`, `240/4` (including broadcast), the - documentation ranges, `64:ff9b:1::/48`, Teredo (`2001::/32`), `100::/64` - and `fec0::/10`. Generated Go and JavaScript render the same table. + documentation ranges, `64:ff9b:1::/48`, Teredo (`2001::/32`), `100::/64`, + `fec0::/10`, `3fff::/20` and `5f00::/16`. Generated Go and JavaScript render the same table. IPv4-mapped IPv6 is checked as IPv4, and IPv4 embedded in IPv4-compatible - (`::a.b.c.d`) and 6to4 (`2002::/16`) addresses is decoded and checked. Go checks the - resolved address before dialing and again at socket creation. Node pins - its connection to the address checked by its lookup. + (`::a.b.c.d`), IPv4-translated (`::ffff:0:a.b.c.d`) and 6to4 (`2002::/16`) + addresses is decoded and checked. Go checks every resolved address before + dialing and again at socket creation. Node vets every address its lookup + returns and pins the connection to that whole vetted set (#177). * Explicit IP-literal grants and the exact name `localhost` are deliberate - opt-outs for those ranges. They still require the exact host and port; + opt-outs for those ranges. `localhost` reaches loopback only: if it + resolves to any non-loopback address the fetch is refused (#177). They + still require the exact host and port; `localhost` does not authorize `127.0.0.1`, or vice versa. Multicast (`224.0.0.0/4`, `ff00::/8`) is always refused, even with a literal grant. * A redirect may land on an IP-literal or `localhost` destination only if the request started on that same host. A hostname grant can't be redirected into a granted private IP or `localhost`, and `localhost` can't be redirected to `127.0.0.1` (or the reverse). +* An IPv4-mapped literal `[::ffff:a.b.c.d]` is the same host as `a.b.c.d` + on every target, for grant matching, opt-out redirects and origin + comparison (#177). +* A redirect target carrying userinfo (`user:pw@` or `user@`) is refused. * HTTPS-to-HTTP redirects are refused even when both hosts are granted. - On any host, effective port or scheme change, Authorization and Cookie - headers are removed for the rest of the chain, including subsequent hops - on the new origin. + On any host, effective port or scheme change, every request header except + the shared `capability.RedirectSafeHeaders` (`Accept`, `Accept-Encoding`, + `Accept-Language`, `Content-Type`, `Content-Length`, `Content-Encoding`, + `User-Agent`) is removed for the rest of the chain, including subsequent + hops on the new origin. This drops `Authorization`, `Proxy-Authorization`, + `Cookie` and custom auth headers such as `X-Api-Key` (#177). * `HTTP_PROXY` / `HTTPS_PROXY` (and lowercase variants) are ignored by governed fetches. Node 24 with `NODE_USE_ENV_PROXY=1` honors proxy env in its native fetch; the governed Node path uses native HTTP requests with diff --git a/docs/reference/process_authority.md b/docs/reference/process_authority.md index 62f06c3e..79880be5 100644 --- a/docs/reference/process_authority.md +++ b/docs/reference/process_authority.md @@ -118,14 +118,24 @@ Rules, enforced identically on the interpreter (`-run`), `-run-bc` (both ## Environment pass-through and receipts (#166) -* `process:env=NAME` refuses dynamic-loader variables: any name starting with - `LD_` (including `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`) or `DYLD_`, - plus interpreter code-loading variables `PYTHONPATH`, `PYTHONSTARTUP`, - `NODE_OPTIONS`, `NODE_PATH`, `BASH_ENV`, `ENV`, `PERL5LIB`, `PERL5OPT`, - `RUBYOPT` and `RUBYLIB`, compared case-insensitively. Passing one to an allow-listed program would - let it load arbitrary code. The grant is rejected at parse time on the - interpreter and `-run-bc`; generated Go and JavaScript skip such names - when building the child environment. +* `process:env=NAME` refuses code-loading, startup, config-lookup, trust-store + and program-search variables, compared case-insensitively. The one table is + `capability.CodeLoadingEnvNames` plus the prefixes in + `capability.CodeLoadingEnvPrefixes` (`LD_*`, `DYLD_*`, `GIT_*`, + `BASH_FUNC_*`, `LUA_*`); generated Go and JavaScript render the same tables + and a parity test runs one independent list against all three (#177). It + includes `PATH`, `HOME`, `XDG_CONFIG_HOME`/`XDG_CONFIG_DIRS`, shell options + (`SHELLOPTS`, `BASHOPTS`, `PS4`, `PROMPT_COMMAND`, `IFS`), Python + (`PYTHONPATH`, `PYTHONHOME`, `PYTHONSTARTUP`, ...), Node (`NODE_OPTIONS`, + `NODE_PATH`, `NODE_EXTRA_CA_CERTS`), Perl (`PERL5LIB`, `PERLLIB`, + `PERL5OPT`, `PERL5DB`), Ruby, Go (`GOFLAGS`, `GODEBUG`, ...), Java + (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`, `CLASSPATH`), + `GCONV_PATH`/`LOCPATH` and TLS trust stores (`SSL_CERT_FILE`, ...). Passing + one to an allow-listed program would let it load arbitrary code or config. + The grant is rejected at parse time on the interpreter and `-run-bc`; + generated Go and JavaScript skip such names when building the child + environment. An exec child that needs a search path should be granted by + absolute path; it starts with no `PATH`. * Receipt `grant` strings are copied verbatim from the normalized grant. An allowed exec records the matched entry with every pattern literal exactly as written (for example `process:exec=/usr/bin/git log --oneline *`).