diff --git a/netutil/ssrf.go b/netutil/ssrf.go index 6974187d..f8aa99b1 100644 --- a/netutil/ssrf.go +++ b/netutil/ssrf.go @@ -41,8 +41,9 @@ func EnforceWSSURL(rawURL string, dev bool) (*url.URL, error) { switch u.Scheme { case "wss": case "ws": - if !dev { - return nil, fmt.Errorf("insecure scheme %q is prohibited in production; use wss://", u.Scheme) + host := net.ParseIP(u.Hostname()) + if !dev && (host == nil || !host.IsLoopback()) { + return nil, fmt.Errorf("insecure scheme %q is prohibited outside loopback in production; use wss://", u.Scheme) } default: return nil, fmt.Errorf("unsupported websocket scheme %q", u.Scheme) diff --git a/netutil/ssrf_test.go b/netutil/ssrf_test.go index 8ca51fe7..3b5d8298 100644 --- a/netutil/ssrf_test.go +++ b/netutil/ssrf_test.go @@ -15,3 +15,29 @@ func TestSSRFWebsocketDialerPreservesHandshakeTimeout(t *testing.T) { t.Fatal("NetDialContext is nil; public-only dialing is not enforced") } } + +func TestEnforceWSSURLAllowsProductionLoopback(t *testing.T) { + for _, rawURL := range []string{ + "ws://127.0.0.1:6555/mill", + "ws://[::1]:6555/mill", + } { + t.Run(rawURL, func(t *testing.T) { + if _, err := EnforceWSSURL(rawURL, false); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestEnforceWSSURLRejectsProductionCleartextOffHost(t *testing.T) { + for _, rawURL := range []string{ + "ws://192.0.2.1:6555/mill", + "ws://mill.example.com/mill", + } { + t.Run(rawURL, func(t *testing.T) { + if _, err := EnforceWSSURL(rawURL, false); err == nil { + t.Fatalf("EnforceWSSURL(%q) accepted production cleartext", rawURL) + } + }) + } +} diff --git a/spindle/mill/executor/executor.go b/spindle/mill/executor/executor.go index ce9a274e..cdc0c96a 100644 --- a/spindle/mill/executor/executor.go +++ b/spindle/mill/executor/executor.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/gorilla/websocket" "log/slog" "strings" @@ -190,7 +191,7 @@ func (e *Executor) runSession(ctx context.Context) error { if e.token != "" { header.Set("Authorization", "Bearer "+e.token) } - conn, _, err := netutil.SSRFWebsocketDialer(dev).DialContext(ctx, e.millURL, header) + conn, _, err := websocket.DefaultDialer.DialContext(ctx, e.millURL, header) if err != nil { return fmt.Errorf("dial mill: %w", err) } diff --git a/spindle/mill/integration_test.go b/spindle/mill/integration_test.go index 0aad2795..07983519 100644 --- a/spindle/mill/integration_test.go +++ b/spindle/mill/integration_test.go @@ -48,7 +48,6 @@ func TestEndToEndDummyJob(t *testing.T) { } en := notifier.New() cfg := &config.Config{} - cfg.Server.Dev = true cfg.Server.LogDir = execDir cfg.ArtifactStores.Disk.Dir = filepath.Join(execDir, "artifacts") cfg.Server.Hostname = "exec-1"