CVE-2026-33621
Description
PinchTab is a standalone HTTP server that gives AI agents direct control over a Chrome browser. PinchTab v0.7.7 through v0.8.4 contain incomplete request-throttling protections for auth-checkable endpoints. In v0.7.7 through v0.8.3, a fully implemented RateLimitMiddleware existed in internal/handlers/middleware.go but was not inserted into the production HTTP handler chain, so requests were not subject to the intended per-IP throttle. In the same pre-v0.8.4 range, the original limiter also keyed clients using X-Forwarded-For, which would have allowed client-controlled header spoofing if the middleware had been enabled. v0.8.4 addressed those two issues by wiring the limiter into the live handler chain and switching the key to the immediate peer IP, but it still exempted /health and /metrics from rate limiting even though /health remained an auth-checkable endpoint when a token was configured. This issue weakens defense in depth for deployments where an attacker can reach the API, especially if a weak human-chosen token is used. It is not a direct authentication bypass or token disclosure issue by itself. PinchTab is documented as local-first by default and uses 127.0.0.1 plus a generated random token in the recommended setup. PinchTab's default deployment model is a local-first, user-controlled environment between the user and their agents; wider exposure is an intentional operator choice. This lowers practical risk in the default configuration, even though it does not by itself change the intrinsic base characteristics of the bug. This was fully addressed in v0.8.5 by applying RateLimitMiddleware in the production handler chain, deriving the client address from the immediate peer IP instead of trusting forwarded headers by default, and removing the /health and /metrics exemption so auth-checkable endpoints are throttled as well.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/pinchtab/pinchtabGo | >= 0.7.7, < 0.8.5 | 0.8.5 |
Affected products
1Patches
1c619c43a4f29fix: dashboard security hardening (#363)
47 files changed · +1592 −309
cmd/pinchtab/cmd_cli_clipboard.go+1 −1 modified@@ -13,7 +13,7 @@ import ( var clipboardCmd = &cobra.Command{ Use: "clipboard", Short: "Clipboard operations", - Long: "Read and write shared clipboard content.", + Long: "Read and write the shared server clipboard.", } var clipboardReadCmd = &cobra.Command{
dashboard/src/index.css+3 −4 modified@@ -1,12 +1,11 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); @import "tailwindcss"; @theme { --font-sans: - "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-mono: - "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; + "SFMono-Regular", "Cascadia Code", "Fira Code", Consolas, ui-monospace, + monospace; --color-bg-app: #0f1117; --color-bg-surface: #1a1d27;
dashboard/src/pages/LoginPage.tsx+9 −2 modified@@ -29,7 +29,7 @@ export default function LoginPage() { setError(""); try { await api.login(token); - void storeTokenCredential(token); + await storeTokenCredential(token, event.currentTarget); dispatchAuthStateChanged(); navigate(from, { replace: true }); } catch (e) { @@ -53,8 +53,14 @@ export default function LoginPage() { </p> </div> - <form className="space-y-4" autoComplete="on" onSubmit={handleSubmit}> + <form + id="login-form" + className="space-y-4" + autoComplete="on" + onSubmit={handleSubmit} + > <input + id="login-username" type="text" name="username" autoComplete="username" @@ -65,6 +71,7 @@ export default function LoginPage() { className="sr-only" /> <input + id="login-password" type="password" autoFocus name="password"
dashboard/src/pages/SettingsPage.tsx+3 −1 modified@@ -360,7 +360,7 @@ export default function SettingsPage() { try { await api.elevate(elevationToken); - void storeTokenCredential(elevationToken); + await storeTokenCredential(elevationToken, event.currentTarget); setPendingElevatedAction(null); setElevationToken(""); @@ -463,6 +463,7 @@ export default function SettingsPage() { this for every admin action. </p> <input + id="settings-elevation-username" type="text" name="username" autoComplete="username" @@ -473,6 +474,7 @@ export default function SettingsPage() { className="sr-only" /> <Input + id="settings-elevation-password" type="password" name="password" autoComplete="current-password"
dashboard/src/services/auth.ts+48 −8 modified@@ -26,13 +26,43 @@ export function credentialUsername(): string { return `${CREDENTIAL_USERNAME_PREFIX}@${window.location.host}`; } -type PasswordCredentialConstructor = new (data: { +type PasswordCredentialData = { id: string; password: string; name?: string; -}) => Credential; +}; -export async function storeTokenCredential(token: string): Promise<void> { +type PasswordCredentialConstructor = { + new (form: HTMLFormElement): Credential; + new (data: PasswordCredentialData): Credential; +}; + +function setCredentialFormValues( + form: HTMLFormElement, + token: string, +): PasswordCredentialData { + const id = credentialUsername(); + const usernameField = form.elements.namedItem("username"); + if (usernameField instanceof HTMLInputElement) { + usernameField.value = id; + } + + const passwordField = form.elements.namedItem("password"); + if (passwordField instanceof HTMLInputElement) { + passwordField.value = token; + } + + return { + id, + password: token, + name: `PinchTab ${window.location.host}`, + }; +} + +export async function storeTokenCredential( + token: string, + form?: HTMLFormElement, +): Promise<void> { const trimmed = token.trim(); if ( trimmed === "" || @@ -50,11 +80,21 @@ export async function storeTokenCredential(token: string): Promise<void> { } try { - const credential = new PasswordCredentialImpl({ - id: credentialUsername(), - password: trimmed, - name: `PinchTab ${window.location.host}`, - }); + let credential: Credential; + if (form) { + const fallback = setCredentialFormValues(form, trimmed); + try { + credential = new PasswordCredentialImpl(form); + } catch { + credential = new PasswordCredentialImpl(fallback); + } + } else { + credential = new PasswordCredentialImpl({ + id: credentialUsername(), + password: trimmed, + name: `PinchTab ${window.location.host}`, + }); + } await navigator.credentials.store(credential); } catch { // Ignore password-manager failures and continue with the session flow.
internal/bridge/runtime/init.go+4 −1 modified@@ -154,7 +154,10 @@ func setupAllocator(cfg *config.RuntimeConfig, hooks Hooks) (context.Context, co } debugPort := 0 - if port, err := findFreePort(cfg.InstancePortStart, cfg.InstancePortEnd); err == nil { + if cfg.ChromeDebugPort > 0 { + debugPort = cfg.ChromeDebugPort + opts = append(opts, chromedp.Flag("remote-debugging-port", strconv.Itoa(debugPort))) + } else if port, err := findFreePort(cfg.InstancePortStart, cfg.InstancePortEnd); err == nil { debugPort = port opts = append(opts, chromedp.Flag("remote-debugging-port", strconv.Itoa(port))) }
internal/config/config_file.go+11 −0 modified@@ -137,6 +137,7 @@ type serverConfigJSON struct { type browserConfigJSON struct { ChromeVersion string `json:"version"` ChromeBinary string `json:"binary"` + ChromeDebugPort *int `json:"remoteDebuggingPort,omitempty"` ChromeExtraFlags string `json:"extraFlags"` ExtensionPaths []string `json:"extensionPaths"` } @@ -263,6 +264,7 @@ func (fc FileConfig) MarshalJSON() ([]byte, error) { Browser: browserConfigJSON{ ChromeVersion: fc.Browser.ChromeVersion, ChromeBinary: fc.Browser.ChromeBinary, + ChromeDebugPort: fc.Browser.ChromeDebugPort, ChromeExtraFlags: fc.Browser.ChromeExtraFlags, ExtensionPaths: copyStringSlice(fc.Browser.ExtensionPaths), }, @@ -413,6 +415,7 @@ func FileConfigFromRuntime(cfg *RuntimeConfig) FileConfig { Browser: BrowserConfig{ ChromeVersion: cfg.ChromeVersion, ChromeBinary: cfg.ChromeBinary, + ChromeDebugPort: intPtrIfPositive(cfg.ChromeDebugPort), ChromeExtraFlags: cfg.ChromeExtraFlags, ExtensionPaths: append([]string(nil), cfg.ExtensionPaths...), }, @@ -485,6 +488,14 @@ func FileConfigFromRuntime(cfg *RuntimeConfig) FileConfig { return fc } +func intPtrIfPositive(v int) *int { + if v <= 0 { + return nil + } + n := v + return &n +} + // legacyFileConfig is the old flat structure for backward compatibility. type legacyFileConfig struct { Port string `json:"port"`
internal/config/config_load.go+3 −0 modified@@ -259,6 +259,9 @@ func applyFileConfig(cfg *RuntimeConfig, fc *FileConfig) { if fc.Browser.ChromeBinary != "" { cfg.ChromeBinary = fc.Browser.ChromeBinary } + if fc.Browser.ChromeDebugPort != nil && *fc.Browser.ChromeDebugPort > 0 { + cfg.ChromeDebugPort = *fc.Browser.ChromeDebugPort + } if fc.Browser.ChromeExtraFlags != "" { cfg.ChromeExtraFlags = fc.Browser.ChromeExtraFlags }
internal/config/config_types.go+2 −0 modified@@ -43,6 +43,7 @@ type RuntimeConfig struct { MaxTabs int MaxParallelTabs int // 0 = auto-detect from runtime.NumCPU ChromeBinary string + ChromeDebugPort int ChromeExtraFlags string ExtensionPaths []string UserAgent string @@ -149,6 +150,7 @@ type ServerConfig struct { type BrowserConfig struct { ChromeVersion string `json:"version,omitempty"` ChromeBinary string `json:"binary,omitempty"` + ChromeDebugPort *int `json:"remoteDebuggingPort,omitempty"` ChromeExtraFlags string `json:"extraFlags,omitempty"` ExtensionPaths []string `json:"extensionPaths,omitempty"` }
internal/handlers/clipboard.go+19 −3 modified@@ -5,14 +5,14 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync" "github.com/pinchtab/pinchtab/internal/httpx" ) type clipboardRequest struct { - TabID string `json:"tabId"` - Text *string `json:"text"` + Text *string `json:"text"` } const maxClipboardTextBytes = 64 << 10 @@ -41,6 +41,14 @@ func (h *Handlers) clipboardEnabled() bool { return h != nil && h.Config != nil && h.Config.AllowClipboard } +func rejectClipboardTabID(w http.ResponseWriter, r *http.Request) bool { + if strings.TrimSpace(r.URL.Query().Get("tabId")) != "" { + httpx.Error(w, http.StatusBadRequest, fmt.Errorf("tabId is not supported for shared clipboard operations")) + return true + } + return false +} + // HandleClipboardRead reads text from the clipboard. func (h *Handlers) HandleClipboardRead(w http.ResponseWriter, r *http.Request) { if !h.clipboardEnabled() { @@ -49,6 +57,9 @@ func (h *Handlers) HandleClipboardRead(w http.ResponseWriter, r *http.Request) { }) return } + if rejectClipboardTabID(w, r) { + return + } text := h.clipboard.Read() @@ -70,9 +81,14 @@ func (h *Handlers) HandleClipboardWrite(w http.ResponseWriter, r *http.Request) }) return } + if rejectClipboardTabID(w, r) { + return + } var req clipboardRequest - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodySize)).Decode(&req); err != nil { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodySize)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, fmt.Errorf("decode: %w", err)) return }
internal/handlers/clipboard_test.go+14 −5 modified@@ -37,19 +37,28 @@ func TestHandleClipboardReadWriteWithoutTabs(t *testing.T) { } } -func TestHandleClipboardReadIgnoresTabID(t *testing.T) { +func TestHandleClipboardReadRejectsTabID(t *testing.T) { h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{AllowClipboard: true}, nil, nil, nil) h.clipboard.Write("shared") req := httptest.NewRequest(http.MethodGet, "/clipboard/read?tabId=missing", nil) w := httptest.NewRecorder() h.HandleClipboardRead(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected read status 200, got %d: %s", w.Code, w.Body.String()) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected read status 400, got %d: %s", w.Code, w.Body.String()) } - if !strings.Contains(w.Body.String(), `"text":"shared"`) { - t.Fatalf("expected shared clipboard text, got %s", w.Body.String()) +} + +func TestHandleClipboardWriteRejectsTabIDInBody(t *testing.T) { + h := New(&mockBridge{}, &config.RuntimeConfig{AllowClipboard: true}, nil, nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/clipboard/write", bytes.NewReader([]byte(`{"text":"hello","tabId":"tab1"}`))) + w := httptest.NewRecorder() + h.HandleClipboardWrite(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected write status 400, got %d: %s", w.Code, w.Body.String()) } }
internal/handlers/download.go+18 −55 modified@@ -5,9 +5,7 @@ import ( "encoding/base64" "errors" "fmt" - "net" "net/http" - "net/netip" "net/url" "os" "path/filepath" @@ -27,19 +25,11 @@ import ( "github.com/pinchtab/pinchtab/internal/config" "github.com/pinchtab/pinchtab/internal/httpx" "github.com/pinchtab/pinchtab/internal/idpi" + "github.com/pinchtab/pinchtab/internal/netguard" ) -var resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { - return net.DefaultResolver.LookupIP(ctx, network, host) -} - var errDownloadTooLarge = errors.New("download response too large") -var blockedDownloadPrefixes = []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/10"), // Carrier-grade NAT/shared address space. - netip.MustParsePrefix("198.18.0.0/15"), // Benchmark/testing networks. -} - type downloadURLGuard struct { allowedDomains []string } @@ -58,28 +48,22 @@ func (g *downloadURLGuard) Validate(rawURL string) error { return fmt.Errorf("only http/https schemes are allowed") } - host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") - if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { + host := netguard.NormalizeHost(parsed.Hostname()) + if host == "" || netguard.IsLocalHost(host) { return fmt.Errorf("internal or blocked host") } - if ip := net.ParseIP(host); ip != nil { - if err := validateDownloadIP(ip); err != nil { - return err - } - } else { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() - ips, err := resolveDownloadHostIPs(ctx, "ip", host) - if err != nil { + if _, err := netguard.ResolveAndValidatePublicIPs(ctx, host); err != nil { + if errors.Is(err, netguard.ErrResolveHost) { return fmt.Errorf("could not resolve host") } - for _, ip := range ips { - if err := validateDownloadIP(ip); err != nil { - return err - } + if errors.Is(err, netguard.ErrPrivateInternalIP) { + return fmt.Errorf("private/internal IP blocked") } + return fmt.Errorf("could not resolve host") } if result := idpi.CheckDomain(rawURL, config.IDPIConfig{ @@ -92,27 +76,6 @@ func (g *downloadURLGuard) Validate(rawURL string) error { return nil } -func validateDownloadIP(ip net.IP) error { - if ip == nil { - return fmt.Errorf("private/internal IP blocked") - } - - addr, ok := netip.AddrFromSlice(ip) - if !ok { - return fmt.Errorf("private/internal IP blocked") - } - addr = addr.Unmap() - if addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsInterfaceLocalMulticast() || addr.IsMulticast() || addr.IsUnspecified() { - return fmt.Errorf("private/internal IP blocked") - } - for _, prefix := range blockedDownloadPrefixes { - if prefix.Contains(addr) { - return fmt.Errorf("private/internal IP blocked") - } - } - return nil -} - func validateDownloadRemoteIPAddress(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { @@ -121,16 +84,16 @@ func validateDownloadRemoteIPAddress(raw string) error { return nil } - raw = strings.TrimPrefix(raw, "[") - raw = strings.TrimSuffix(raw, "]") - - ip := net.ParseIP(raw) - if ip == nil { + normalized := netguard.NormalizeRemoteIP(raw) + if err := netguard.ValidateRemoteIPAddress(raw); err != nil { + if errors.Is(err, netguard.ErrUnparseableRemoteIP) { + return fmt.Errorf("download connected to an unparseable remote IP %q", normalized) + } + if errors.Is(err, netguard.ErrPrivateInternalIP) { + return fmt.Errorf("download connected to blocked remote IP %s", normalized) + } return fmt.Errorf("download connected to an unparseable remote IP %q", raw) } - if err := validateDownloadIP(ip); err != nil { - return fmt.Errorf("download connected to blocked remote IP %s", raw) - } return nil }
internal/handlers/download_test.go+26 −48 modified@@ -15,6 +15,7 @@ import ( "github.com/chromedp/chromedp" "github.com/pinchtab/pinchtab/internal/bridge" "github.com/pinchtab/pinchtab/internal/config" + "github.com/pinchtab/pinchtab/internal/netguard" ) type downloadPolicyBridge struct { @@ -45,6 +46,15 @@ func (m *downloadPolicyBridge) GetTabPolicyState(tabID string) (bridge.TabPolicy return m.policy, m.hasState } +func stubDownloadHostResolution(t *testing.T, fn func(context.Context, string, string) ([]net.IP, error)) { + t.Helper() + originalResolver := netguard.ResolveHostIPs + netguard.ResolveHostIPs = fn + t.Cleanup(func() { + netguard.ResolveHostIPs = originalResolver + }) +} + func TestHandleDownload_MissingURL(t *testing.T) { h := New(&mockBridge{}, &config.RuntimeConfig{AllowDownload: true}, nil, nil, nil) req := httptest.NewRequest("GET", "/download", nil) @@ -66,11 +76,7 @@ func TestHandleDownload_EmptyURL(t *testing.T) { } func TestValidateDownloadURL(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil @@ -79,7 +85,7 @@ func TestValidateDownloadURL(t *testing.T) { default: return nil, errors.New("not found") } - } + }) tests := []struct { name string @@ -191,18 +197,14 @@ func TestHandleDownload_Disabled(t *testing.T) { } func TestDownloadRequestGuard_BlocksBrowserSideRequests(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) guard := newDownloadRequestGuard(newDownloadURLGuard(nil), -1) err := guard.Validate("http://127.0.0.1:1337/increment", false) @@ -215,18 +217,14 @@ func TestDownloadRequestGuard_BlocksBrowserSideRequests(t *testing.T) { } func TestDownloadURLGuard_EnforcesAllowedDomains(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com", "cdn.pinchtab.com", "example.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) guard := newDownloadURLGuard([]string{"pinchtab.com", "*.pinchtab.com"}) @@ -279,18 +277,14 @@ func TestParseContentLengthHeader(t *testing.T) { } func TestHandleDownload_TabLocked(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) b := &downloadPolicyBridge{ lock: &bridge.LockInfo{ @@ -308,18 +302,14 @@ func TestHandleDownload_TabLocked(t *testing.T) { } func TestHandleDownload_TabScopedCrossOriginBlockedForCookieAuth(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com", "example.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) b := &downloadPolicyBridge{ hasState: true, @@ -345,18 +335,14 @@ func TestHandleDownload_TabScopedCrossOriginBlockedForCookieAuth(t *testing.T) { } func TestHandleDownload_TabScopedCrossOriginAllowedForHeaderAuth(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com", "example.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) b := &downloadPolicyBridge{ hasState: true, @@ -382,18 +368,14 @@ func TestHandleDownload_TabScopedCrossOriginAllowedForHeaderAuth(t *testing.T) { } func TestDownloadRequestGuard_TracksRedirectLimits(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) guard := newDownloadRequestGuard(newDownloadURLGuard(nil), 0) err := guard.Validate("https://pinchtab.com/redirected", true) @@ -403,18 +385,14 @@ func TestDownloadRequestGuard_TracksRedirectLimits(t *testing.T) { } func TestHandleDownload_RejectsURLOutsideAllowedDomains(t *testing.T) { - originalResolver := resolveDownloadHostIPs - t.Cleanup(func() { - resolveDownloadHostIPs = originalResolver - }) - resolveDownloadHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + stubDownloadHostResolution(t, func(ctx context.Context, network, host string) ([]net.IP, error) { switch host { case "pinchtab.com", "example.com": return []net.IP{net.ParseIP("93.184.216.34")}, nil default: return nil, errors.New("not found") } - } + }) h := New(&mockBridge{}, &config.RuntimeConfig{ AllowDownload: true,
internal/handlers/handlers_test.go+5 −0 modified@@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "strings" @@ -191,6 +192,10 @@ func TestOpenAPIIncludesSensitiveEndpointStatus(t *testing.T) { } func TestHandleNavigate(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil + }) + cfg := &config.RuntimeConfig{} m := &mockBridge{} h := New(m, cfg, nil, nil, nil)
internal/handlers/help.go+4 −3 modified@@ -35,15 +35,16 @@ func (h *Handlers) HandleHelp(wr http.ResponseWriter, _ *http.Request) { "POST /tabs/{id}/upload": endpointStatusSummary(security["upload"], "set files on a file input in a specific tab"), "GET /screencast": endpointStatusSummary(security["screencast"], "stream live tab frames"), "GET /screencast/tabs": endpointStatusSummary(security["screencast"], "list tabs available for live capture"), - "GET /clipboard/read": endpointStatusSummary(security["clipboard"], "read shared clipboard text"), - "POST /clipboard/write": endpointStatusSummary(security["clipboard"], "write shared clipboard text (body: {text})"), + "GET /clipboard/read": endpointStatusSummary(security["clipboard"], "read shared server clipboard text (not tab-scoped)"), + "POST /clipboard/write": endpointStatusSummary(security["clipboard"], "write shared server clipboard text (body: {text})"), "POST /clipboard/copy": endpointStatusSummary(security["clipboard"], "alias for clipboard write"), - "GET /clipboard/paste": endpointStatusSummary(security["clipboard"], "read shared clipboard text (alias for read)"), + "GET /clipboard/paste": endpointStatusSummary(security["clipboard"], "read shared server clipboard text (alias for read)"), }, "security": security, "notes": []string{ "Use Authorization: Bearer <token> when auth is enabled.", "Prefer /text with maxChars for token-efficient reads.", + "Clipboard endpoints operate on shared server-side state and do not accept tabId.", }, }) }
internal/handlers/limits.go+3 −0 added@@ -0,0 +1,3 @@ +package handlers + +const maxBodySize = 1 << 20
internal/handlers/middleware.go+18 −5 modified@@ -25,6 +25,11 @@ var ( metricStaleRefRetries uint64 ) +const ( + defaultCSP = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:" + strictTransportSecurity = "max-age=31536000" +) + func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -54,6 +59,19 @@ func LoggingMiddleware(next http.Handler) http.Handler { }) } +func SecurityHeadersMiddleware(cfg *config.RuntimeConfig, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", defaultCSP) + trustProxy := cfg != nil && cfg.TrustProxyHeaders + if requestScheme(r, trustProxy) == "https" { + w.Header().Set("Strict-Transport-Security", strictTransportSecurity) + } + next.ServeHTTP(w, r) + }) +} + func AuthMiddleware(cfg *config.RuntimeConfig, next http.Handler) http.Handler { return AuthMiddlewareWithSessions(cfg, nil, next) } @@ -340,11 +358,6 @@ const ( func RateLimitMiddleware(next http.Handler) http.Handler { startRateLimiterJanitor(rateLimitWindow, evictionInterval) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimSpace(r.URL.Path) - if p == "/health" || p == "/metrics" || strings.HasPrefix(p, "/health/") || strings.HasPrefix(p, "/metrics/") { - next.ServeHTTP(w, r) - return - } host := authn.ClientIP(r) now := time.Now()
internal/handlers/middleware_test.go+82 −5 modified@@ -1,6 +1,7 @@ package handlers import ( + "crypto/tls" "net/http" "net/http/httptest" "testing" @@ -32,6 +33,59 @@ func TestAuthMiddleware_NoToken(t *testing.T) { } } +func TestSecurityHeadersMiddleware_AddsHeaders(t *testing.T) { + handler := SecurityHeadersMiddleware(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } + if got := w.Header().Get("X-Frame-Options"); got != "DENY" { + t.Fatalf("X-Frame-Options = %q, want DENY", got) + } + if got := w.Header().Get("Content-Security-Policy"); got != defaultCSP { + t.Fatalf("Content-Security-Policy = %q, want %q", got, defaultCSP) + } + if got := w.Header().Get("Strict-Transport-Security"); got != "" { + t.Fatalf("Strict-Transport-Security = %q, want empty for http requests", got) + } +} + +func TestSecurityHeadersMiddleware_AddsHSTSForTLS(t *testing.T) { + handler := SecurityHeadersMiddleware(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + request := httptest.NewRequest(http.MethodGet, "https://pinchtab.test/dashboard", nil) + request.TLS = &tls.ConnectionState{} + w := httptest.NewRecorder() + handler.ServeHTTP(w, request) + + if got := w.Header().Get("Strict-Transport-Security"); got != strictTransportSecurity { + t.Fatalf("Strict-Transport-Security = %q, want %q", got, strictTransportSecurity) + } +} + +func TestSecurityHeadersMiddleware_UsesTrustedForwardedProtoForHSTS(t *testing.T) { + handler := SecurityHeadersMiddleware(&config.RuntimeConfig{TrustProxyHeaders: true}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + request := httptest.NewRequest(http.MethodGet, "http://pinchtab/dashboard", nil) + request.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + handler.ServeHTTP(w, request) + + if got := w.Header().Get("Strict-Transport-Security"); got != strictTransportSecurity { + t.Fatalf("Strict-Transport-Security = %q, want %q", got, strictTransportSecurity) + } +} + func TestAuthMiddleware_ValidToken(t *testing.T) { cfg := &config.RuntimeConfig{Token: "secret123"} @@ -640,6 +694,10 @@ func TestRequestIDMiddleware_SetsHeader(t *testing.T) { } func TestRateLimitMiddleware_AllowsRequest(t *testing.T) { + rateMu.Lock() + rateBuckets = map[string][]time.Time{} + rateMu.Unlock() + handler := RateLimitMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) @@ -652,17 +710,36 @@ func TestRateLimitMiddleware_AllowsRequest(t *testing.T) { } } -func TestRateLimitMiddleware_BypassHealthAndMetrics(t *testing.T) { +func TestRateLimitMiddleware_HealthAndMetricsAreRateLimited(t *testing.T) { + rateMu.Lock() + rateBuckets = map[string][]time.Time{} + rateMu.Unlock() + handler := RateLimitMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) - for _, p := range []string{"/health", "/metrics"} { - req := httptest.NewRequest("GET", p, nil) + + for _, path := range []string{"/health", "/metrics"} { + rateMu.Lock() + rateBuckets = map[string][]time.Time{} + rateMu.Unlock() + + for i := 0; i < rateLimitMaxReq; i++ { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d for %s: expected 200, got %d", i+1, path, w.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, path, nil) req.RemoteAddr = "127.0.0.1:12345" w := httptest.NewRecorder() handler.ServeHTTP(w, req) - if w.Code != 200 { - t.Fatalf("expected 200 for %s, got %d", p, w.Code) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 for %s after limit exceeded, got %d", path, w.Code) } } }
internal/handlers/navigation.go+39 −71 modified@@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strconv" "strings" "time" @@ -21,8 +20,6 @@ import ( "github.com/pinchtab/pinchtab/internal/idpi" ) -const maxBodySize = 1 << 20 - // HandleNavigate navigates a tab to a URL or creates a new tab. // // @Endpoint POST /navigate @@ -91,8 +88,23 @@ func (h *Handlers) HandleNavigate(w http.ResponseWriter, r *http.Request) { } } - if req.URL == "" { - httpx.Error(w, 400, fmt.Errorf("url required")) + if err := validateNavigateURL(req.URL); err != nil { + httpx.Error(w, 400, err) + return + } + + domainResult := idpi.CheckDomain(req.URL, h.Config.IDPI) + if domainResult.Blocked { + httpx.Error(w, http.StatusForbidden, fmt.Errorf("navigation blocked by IDPI: %s", domainResult.Reason)) + return + } + if domainResult.Threat { + w.Header().Set("X-IDPI-Warning", domainResult.Reason) + } + + target, err := validateNavigateTarget(req.URL, idpi.DomainAllowed(req.URL, h.Config.IDPI)) + if err != nil { + httpx.Error(w, http.StatusForbidden, err) return } h.recordNavigateRequest(r, req.TabID, req.URL) @@ -162,22 +174,6 @@ func (h *Handlers) HandleNavigate(w http.ResponseWriter, r *http.Request) { } if req.NewTab { - // Block dangerous/unsupported schemes; allow bare hostnames (e.g. "pinchtab.com") - // which Chrome handles gracefully by prepending https://. - if parsed, err := url.Parse(req.URL); err == nil && parsed.Scheme != "" { - blocked := parsed.Scheme == "javascript" || parsed.Scheme == "vbscript" || parsed.Scheme == "data" - if blocked { - httpx.Error(w, 400, fmt.Errorf("invalid URL scheme: %s", parsed.Scheme)) - return - } - } - // IDPI: block or warn on non-whitelisted domains before the tab opens. - if result := idpi.CheckDomain(req.URL, h.Config.IDPI); result.Blocked { - httpx.Error(w, http.StatusForbidden, fmt.Errorf("navigation blocked by IDPI: %s", result.Reason)) - return - } else if result.Threat { - w.Header().Set("X-IDPI-Warning", result.Reason) - } // Create a blank tab first so the requested URL becomes the first // real history entry. newTabID, newCtx, _, err := h.Bridge.CreateTab("") @@ -189,12 +185,23 @@ func (h *Handlers) HandleNavigate(w http.ResponseWriter, r *http.Request) { tCtx, tCancel := context.WithTimeout(newCtx, navTimeout) defer tCancel() go httpx.CancelOnClientDone(r.Context(), tCancel) + navGuard, err := installNavigateRuntimeGuard(tCtx, tCancel, target) + if err != nil { + httpx.Error(w, 500, fmt.Errorf("navigation guard: %w", err)) + return + } if len(blockPatterns) > 0 { _ = bridge.SetResourceBlocking(tCtx, blockPatterns) } if err := bridge.NavigatePageWithRedirectLimit(tCtx, req.URL, h.Config.MaxRedirects); err != nil { + if navGuard != nil { + if blockedErr := navGuard.blocked(); blockedErr != nil { + httpx.Error(w, http.StatusForbidden, blockedErr) + return + } + } code := 500 errMsg := err.Error() if errors.Is(err, bridge.ErrTooManyRedirects) { @@ -227,17 +234,14 @@ func (h *Handlers) HandleNavigate(w http.ResponseWriter, r *http.Request) { return } - // IDPI: domain whitelist check also applies when re-navigating an existing tab. - if result := idpi.CheckDomain(req.URL, h.Config.IDPI); result.Blocked { - httpx.Error(w, http.StatusForbidden, fmt.Errorf("navigation blocked by IDPI: %s", result.Reason)) - return - } else if result.Threat { - w.Header().Set("X-IDPI-Warning", result.Reason) - } - tCtx, tCancel := context.WithTimeout(ctx, navTimeout) defer tCancel() go httpx.CancelOnClientDone(r.Context(), tCancel) + navGuard, err := installNavigateRuntimeGuard(tCtx, tCancel, target) + if err != nil { + httpx.Error(w, 500, fmt.Errorf("navigation guard: %w", err)) + return + } if len(blockPatterns) > 0 { _ = bridge.SetResourceBlocking(tCtx, blockPatterns) } else { @@ -246,6 +250,12 @@ func (h *Handlers) HandleNavigate(w http.ResponseWriter, r *http.Request) { } if err := bridge.NavigatePageWithRedirectLimit(tCtx, req.URL, h.Config.MaxRedirects); err != nil { + if navGuard != nil { + if blockedErr := navGuard.blocked(); blockedErr != nil { + httpx.Error(w, http.StatusForbidden, blockedErr) + return + } + } code := 500 errMsg := err.Error() if errors.Is(err, bridge.ErrTooManyRedirects) { @@ -319,48 +329,6 @@ func (h *Handlers) HandleTabNavigate(w http.ResponseWriter, r *http.Request) { h.HandleNavigate(w, req) } -func (h *Handlers) waitForNavigationState(ctx context.Context, waitFor, waitSelector string) error { - waitMode := strings.ToLower(strings.TrimSpace(waitFor)) - switch waitMode { - case "", "none": - return nil - case "dom": - var ready string - return chromedp.Run(ctx, chromedp.Evaluate(`document.readyState`, &ready)) - case "selector": - if waitSelector == "" { - return fmt.Errorf("waitSelector required when waitFor=selector") - } - return chromedp.Run(ctx, chromedp.WaitVisible(waitSelector, chromedp.ByQuery)) - case "networkidle": - // Approximation for "network idle": require fully loaded readyState and no URL changes - var lastURL string - idleChecks := 0 - for i := 0; i < 12; i++ { // up to ~3s - var ready, curURL string - if err := chromedp.Run(ctx, - chromedp.Evaluate(`document.readyState`, &ready), - chromedp.Location(&curURL), - ); err != nil { - return err - } - if ready == "complete" && curURL == lastURL { - idleChecks++ - if idleChecks >= 2 { - return nil - } - } else { - idleChecks = 0 - } - lastURL = curURL - time.Sleep(250 * time.Millisecond) - } - return fmt.Errorf("networkidle wait timed out") - default: - return fmt.Errorf("unsupported waitFor %q (use: none|dom|selector|networkidle)", waitMode) - } -} - const ( tabActionNew = "new" tabActionClose = "close"
internal/handlers/navigation_policy.go+195 −0 added@@ -0,0 +1,195 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + "sync" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + "github.com/pinchtab/pinchtab/internal/netguard" +) + +const maxNavigateURLLen = 8 << 10 + +type validatedNavigateTarget struct { + allowInternal bool +} + +type navigateRuntimeGuard struct { + mu sync.Mutex + mainFrameID string + requestID string + blockedErr error +} + +func (g *navigateRuntimeGuard) noteMainDocumentRequest(frameID, requestID string) { + g.mu.Lock() + defer g.mu.Unlock() + if g.mainFrameID == "" { + g.mainFrameID = frameID + } + if frameID == g.mainFrameID { + g.requestID = requestID + } +} + +func (g *navigateRuntimeGuard) isMainDocumentResponse(requestID string) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.requestID != "" && requestID == g.requestID +} + +func (g *navigateRuntimeGuard) setBlocked(err error) { + if err == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + if g.blockedErr == nil { + g.blockedErr = err + } +} + +func (g *navigateRuntimeGuard) blocked() error { + g.mu.Lock() + defer g.mu.Unlock() + return g.blockedErr +} + +func validateNavigateURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("url required") + } + if len(raw) > maxNavigateURLLen { + return fmt.Errorf("url too long") + } + if strings.EqualFold(raw, "about:blank") { + return nil + } + + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url") + } + if parsed.Scheme == "" { + return nil + } + + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + return nil + default: + return fmt.Errorf("invalid URL scheme: %s", parsed.Scheme) + } +} + +func validateNavigateTarget(raw string, allowExplicitInternal bool) (*validatedNavigateTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.EqualFold(raw, "about:blank") { + return &validatedNavigateTarget{allowInternal: true}, nil + } + + host, hasHost := extractNavigateHost(raw) + if !hasHost { + return &validatedNavigateTarget{}, nil + } + if netguard.IsLocalHost(host) { + return &validatedNavigateTarget{allowInternal: true}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + if _, err := netguard.ResolveAndValidatePublicIPs(ctx, host); err != nil { + if errors.Is(err, netguard.ErrResolveHost) { + return nil, fmt.Errorf("could not resolve navigation host") + } + if errors.Is(err, netguard.ErrPrivateInternalIP) { + if allowExplicitInternal { + return &validatedNavigateTarget{allowInternal: true}, nil + } + return nil, fmt.Errorf("navigation target resolves to blocked private/internal IP") + } + return nil, fmt.Errorf("could not resolve navigation host") + } + return &validatedNavigateTarget{}, nil +} + +func extractNavigateHost(raw string) (string, bool) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", false + } + if host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), "."); host != "" { + return host, true + } + if parsed.Scheme != "" { + return "", false + } + + bare := parsed.Path + bare = strings.SplitN(bare, "/", 2)[0] + bare = strings.SplitN(bare, "?", 2)[0] + bare = strings.SplitN(bare, "#", 2)[0] + bare = strings.TrimSpace(bare) + if bare == "" || strings.HasPrefix(bare, "/") || strings.HasPrefix(bare, ".") { + return "", false + } + if h, _, err := net.SplitHostPort(bare); err == nil { + bare = h + } + host := strings.TrimSuffix(strings.ToLower(bare), ".") + if host == "" { + return "", false + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") || net.ParseIP(host) != nil || strings.Contains(host, ".") || strings.Contains(host, ":") { + return host, true + } + return "", false +} + +func validateNavigateRemoteIPAddress(raw string) error { + normalized := netguard.NormalizeRemoteIP(raw) + if err := netguard.ValidateRemoteIPAddress(raw); err != nil { + return fmt.Errorf("navigation connected to blocked remote IP %s", normalized) + } + return nil +} + +func installNavigateRuntimeGuard(tCtx context.Context, tCancel context.CancelFunc, target *validatedNavigateTarget) (*navigateRuntimeGuard, error) { + if target == nil || target.allowInternal { + return nil, nil + } + if err := chromedp.Run(tCtx, chromedp.ActionFunc(func(ctx context.Context) error { + return network.Enable().Do(ctx) + })); err != nil { + return nil, fmt.Errorf("network enable: %w", err) + } + + guard := &navigateRuntimeGuard{} + chromedp.ListenTarget(tCtx, func(ev interface{}) { + switch e := ev.(type) { + case *network.EventRequestWillBeSent: + if e.Type != network.ResourceTypeDocument { + return + } + guard.noteMainDocumentRequest(string(e.FrameID), string(e.RequestID)) + case *network.EventResponseReceived: + if !guard.isMainDocumentResponse(string(e.RequestID)) { + return + } + if err := validateNavigateRemoteIPAddress(e.Response.RemoteIPAddress); err != nil { + guard.setBlocked(err) + tCancel() + } + } + }) + return guard, nil +}
internal/handlers/navigation_test.go+215 −0 modified@@ -2,12 +2,25 @@ package handlers import ( "bytes" + "context" + "net" "net/http/httptest" + "strings" "testing" "github.com/pinchtab/pinchtab/internal/config" + "github.com/pinchtab/pinchtab/internal/netguard" ) +func stubNavigateHostResolution(t *testing.T, fn func(context.Context, string, string) ([]net.IP, error)) { + t.Helper() + old := netguard.ResolveHostIPs + netguard.ResolveHostIPs = fn + t.Cleanup(func() { + netguard.ResolveHostIPs = old + }) +} + func TestHandleNavigate_InvalidJSON(t *testing.T) { h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`not json`))) @@ -18,7 +31,206 @@ func TestHandleNavigate_InvalidJSON(t *testing.T) { } } +func TestValidateNavigateURL_RejectsUnsupportedSchemes(t *testing.T) { + for _, rawURL := range []string{ + "javascript:alert(1)", + "file:///etc/passwd", + "chrome://settings", + "data:text/html,hello", + } { + if err := validateNavigateURL(rawURL); err == nil { + t.Fatalf("validateNavigateURL(%q) should reject unsupported schemes", rawURL) + } + } +} + +func TestValidateNavigateTarget_AllowsLocalHosts(t *testing.T) { + for _, rawURL := range []string{ + "http://localhost:9867", + "http://127.0.0.1:8080", + "http://[::1]:9222", + "http://foo.localhost:3000", + "about:blank", + } { + target, err := validateNavigateTarget(rawURL, false) + if err != nil { + t.Fatalf("validateNavigateTarget(%q) error = %v", rawURL, err) + } + if target == nil || !target.allowInternal { + t.Fatalf("validateNavigateTarget(%q) should allow local targets", rawURL) + } + } +} + +func TestValidateNavigateTarget_RejectsPrivateLiteralIP(t *testing.T) { + if _, err := validateNavigateTarget("http://192.168.1.10/app", false); err == nil { + t.Fatal("validateNavigateTarget should reject private literal IPs") + } +} + +func TestValidateNavigateTarget_RejectsResolvedPrivateIP(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("192.168.1.10")}, nil + }) + + if _, err := validateNavigateTarget("https://example.com/app", false); err == nil { + t.Fatal("validateNavigateTarget should reject hosts resolving to private IPs") + } +} + +func TestValidateNavigateTarget_AllowsResolvedPrivateIPWhenExplicitlyAllowlisted(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("172.18.0.5")}, nil + }) + + target, err := validateNavigateTarget("http://fixtures:80/app", true) + if err != nil { + t.Fatalf("validateNavigateTarget should allow explicitly allowlisted private targets: %v", err) + } + if target == nil || !target.allowInternal { + t.Fatal("validateNavigateTarget should mark explicitly allowlisted private targets as allowed") + } +} + +func TestValidateNavigateURL_AllowsHTTPHTTPSAndBareHostnames(t *testing.T) { + for _, rawURL := range []string{ + "https://pinchtab.com", + "http://pinchtab.test", + "pinchtab.com", + "about:blank", + } { + if err := validateNavigateURL(rawURL); err != nil { + t.Fatalf("validateNavigateURL(%q) error = %v", rawURL, err) + } + } +} + +func TestValidateNavigateURL_RejectsOverlongURL(t *testing.T) { + rawURL := "https://pinchtab.com/" + strings.Repeat("a", maxNavigateURLLen) + if err := validateNavigateURL(rawURL); err == nil { + t.Fatal("validateNavigateURL should reject overlong urls") + } +} + +func TestHandleNavigate_RejectsUnsupportedSchemeBeforeCreateTab(t *testing.T) { + m := &mockBridge{} + h := New(m, &config.RuntimeConfig{}, nil, nil, nil) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"url":"file:///etc/passwd"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 400 { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + if len(m.createTabURLs) != 0 { + t.Fatalf("CreateTab should not be called for rejected schemes, got %v", m.createTabURLs) + } + if !strings.Contains(w.Body.String(), "invalid URL scheme") { + t.Fatalf("expected invalid URL scheme error, got %s", w.Body.String()) + } +} + +func TestHandleNavigate_RejectsUnsupportedSchemeForExistingTab(t *testing.T) { + h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"tabId":"tab1","url":"javascript:alert(1)"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 400 { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "invalid URL scheme") { + t.Fatalf("expected invalid URL scheme error, got %s", w.Body.String()) + } +} + +func TestHandleNavigate_AllowsLocalhostWithoutResolver(t *testing.T) { + m := &mockBridge{} + h := New(m, &config.RuntimeConfig{}, nil, nil, nil) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"url":"http://localhost:3000"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 200 && w.Code != 500 { + t.Fatalf("expected localhost navigate to proceed, got %d: %s", w.Code, w.Body.String()) + } + if len(m.createTabURLs) == 0 { + t.Fatal("expected CreateTab to be called for localhost navigate") + } +} + +func TestHandleNavigate_RejectsResolvedPrivateIPBeforeCreateTab(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("10.0.0.5")}, nil + }) + + m := &mockBridge{} + h := New(m, &config.RuntimeConfig{}, nil, nil, nil) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"url":"https://example.com"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 403 { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } + if len(m.createTabURLs) != 0 { + t.Fatalf("CreateTab should not be called for blocked targets, got %v", m.createTabURLs) + } + if !strings.Contains(w.Body.String(), "blocked private/internal IP") { + t.Fatalf("expected blocked private/internal IP error, got %s", w.Body.String()) + } +} + +func TestHandleNavigate_AllowsResolvedPrivateIPWhenIDPIAllowlisted(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("172.18.0.5")}, nil + }) + + m := &mockBridge{} + h := New(m, &config.RuntimeConfig{ + IDPI: config.IDPIConfig{ + Enabled: true, + StrictMode: true, + AllowedDomains: []string{"fixtures"}, + }, + }, nil, nil, nil) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"url":"http://fixtures:80/buttons.html"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 200 && w.Code != 500 { + t.Fatalf("expected allowlisted internal navigate to proceed, got %d: %s", w.Code, w.Body.String()) + } + if len(m.createTabURLs) == 0 { + t.Fatal("expected CreateTab to be called for allowlisted internal navigate") + } +} + +func TestHandleNavigate_RejectsOverlongURL(t *testing.T) { + h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) + longURL := "https://pinchtab.com/" + strings.Repeat("a", maxNavigateURLLen) + + req := httptest.NewRequest("POST", "/navigate", bytes.NewReader([]byte(`{"url":"`+longURL+`"}`))) + w := httptest.NewRecorder() + h.HandleNavigate(w, req) + + if w.Code != 400 { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "url too long") { + t.Fatalf("expected url too long error, got %s", w.Body.String()) + } +} + func TestHandleTabNavigate_MissingTabID(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil + }) h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) req := httptest.NewRequest("POST", "/tabs//navigate", bytes.NewReader([]byte(`{"url":"https://pinchtab.com"}`))) w := httptest.NewRecorder() @@ -29,6 +241,9 @@ func TestHandleTabNavigate_MissingTabID(t *testing.T) { } func TestHandleTabNavigate_TabIDMismatch(t *testing.T) { + stubNavigateHostResolution(t, func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil + }) h := New(&mockBridge{}, &config.RuntimeConfig{}, nil, nil, nil) req := httptest.NewRequest("POST", "/tabs/tab_abc/navigate", bytes.NewReader([]byte(`{"tabId":"tab_other","url":"https://pinchtab.com"}`))) req.SetPathValue("id", "tab_abc")
internal/handlers/navigation_wait.go+52 −0 added@@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/chromedp/chromedp" +) + +func (h *Handlers) waitForNavigationState(ctx context.Context, waitFor, waitSelector string) error { + waitMode := strings.ToLower(strings.TrimSpace(waitFor)) + switch waitMode { + case "", "none": + return nil + case "dom": + var ready string + return chromedp.Run(ctx, chromedp.Evaluate(`document.readyState`, &ready)) + case "selector": + if waitSelector == "" { + return fmt.Errorf("waitSelector required when waitFor=selector") + } + return chromedp.Run(ctx, chromedp.WaitVisible(waitSelector, chromedp.ByQuery)) + case "networkidle": + // Approximation for "network idle": require fully loaded readyState and no URL changes. + var lastURL string + idleChecks := 0 + for i := 0; i < 12; i++ { + var ready, curURL string + if err := chromedp.Run(ctx, + chromedp.Evaluate(`document.readyState`, &ready), + chromedp.Location(&curURL), + ); err != nil { + return err + } + if ready == "complete" && curURL == lastURL { + idleChecks++ + if idleChecks >= 2 { + return nil + } + } else { + idleChecks = 0 + } + lastURL = curURL + time.Sleep(250 * time.Millisecond) + } + return fmt.Errorf("networkidle wait timed out") + default: + return fmt.Errorf("unsupported waitFor %q (use: none|dom|selector|networkidle)", waitMode) + } +}
internal/handlers/pdf.go+24 −3 modified@@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -40,10 +41,23 @@ var pdfQueryParams = map[string]struct{}{ "raw": {}, } +var pdfActiveTemplatePattern = regexp.MustCompile(`(?i)<\s*script\b|javascript\s*:|\bon[a-z]+\s*=`) + // HandlePDF generates a PDF of the current tab. // // @Endpoint GET /pdf func (h *Handlers) HandlePDF(w http.ResponseWriter, r *http.Request) { + headerTemplate := r.URL.Query().Get("headerTemplate") + footerTemplate := r.URL.Query().Get("footerTemplate") + if err := validatePDFTemplate(headerTemplate); err != nil { + httpx.Error(w, http.StatusBadRequest, err) + return + } + if err := validatePDFTemplate(footerTemplate); err != nil { + httpx.Error(w, http.StatusBadRequest, err) + return + } + // Ensure Chrome is initialized if err := h.ensureChrome(); err != nil { httpx.Error(w, 500, fmt.Errorf("chrome initialization: %w", err)) @@ -124,9 +138,6 @@ func (h *Handlers) HandlePDF(w http.ResponseWriter, r *http.Request) { } pageRanges := r.URL.Query().Get("pageRanges") // e.g., "1-3,5" - headerTemplate := r.URL.Query().Get("headerTemplate") - footerTemplate := r.URL.Query().Get("footerTemplate") - // IDPI: scan page title, URL, and body text for injection patterns before // rendering to PDF. PDF output is opaque binary — any signal is conveyed // via response headers. The scan timeout is taken from IDPI config so @@ -249,6 +260,16 @@ func (h *Handlers) HandlePDF(w http.ResponseWriter, r *http.Request) { }) } +func validatePDFTemplate(template string) error { + if template == "" { + return nil + } + if pdfActiveTemplatePattern.MatchString(template) { + return fmt.Errorf("invalid pdf template") + } + return nil +} + // HandleTabPDF generates a PDF for a tab identified by path ID. // // @Endpoint GET /tabs/{id}/pdf
internal/handlers/pdf_test.go+31 −0 modified@@ -8,6 +8,37 @@ import ( "github.com/pinchtab/pinchtab/internal/config" ) +func TestValidatePDFTemplate_RejectsActiveContent(t *testing.T) { + tests := []string{ + `<script>alert(1)</script>`, + `<span onclick="alert(1)">x</span>`, + `<a href="javascript:alert(1)">x</a>`, + } + + for _, template := range tests { + if err := validatePDFTemplate(template); err == nil { + t.Fatalf("validatePDFTemplate(%q) should reject active content", template) + } + } +} + +func TestValidatePDFTemplate_AllowsPlaceholderMarkup(t *testing.T) { + template := `<span class="pageNumber"></span> / <span class="totalPages"></span>` + if err := validatePDFTemplate(template); err != nil { + t.Fatalf("validatePDFTemplate() error = %v", err) + } +} + +func TestHandlePDF_InvalidTemplateRejectedBeforeTabLookup(t *testing.T) { + h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{}, nil, nil, nil) + req := httptest.NewRequest("GET", `/pdf?headerTemplate=<script>alert(1)</script>`, nil) + w := httptest.NewRecorder() + h.HandlePDF(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + func TestHandleTabPDF_MissingTabID(t *testing.T) { h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{}, nil, nil, nil) req := httptest.NewRequest("GET", "/tabs//pdf", nil)
internal/handlers/proxy_ws.go+40 −1 modified@@ -14,6 +14,8 @@ import ( internalurls "github.com/pinchtab/pinchtab/internal/urls" ) +const proxyWSBackendAuthorizationHeader = "X-Pinchtab-Proxy-Authorization" + // ProxyWebSocket tunnels WebSocket connections with proper HTTP headers func ProxyWebSocket(w http.ResponseWriter, r *http.Request, targetURL string) { parsed, err := url.Parse(targetURL) @@ -60,7 +62,7 @@ func ProxyWebSocket(w http.ResponseWriter, r *http.Request, targetURL string) { _, _ = fmt.Fprintf(writer, "%s %s HTTP/1.1\r\n", r.Method, path) _, _ = fmt.Fprintf(writer, "Host: %s\r\n", host) - for name, values := range r.Header { + for name, values := range filterProxyWSHeaders(r.Header) { canonicalName := textproto.CanonicalMIMEHeaderKey(name) for _, value := range values { _, _ = fmt.Fprintf(writer, "%s: %s\r\n", canonicalName, value) @@ -86,3 +88,40 @@ func ProxyWebSocket(w http.ResponseWriter, r *http.Request, targetURL string) { <-done } + +func filterProxyWSHeaders(headers http.Header) http.Header { + filtered := make(http.Header) + for name, values := range headers { + if textproto.CanonicalMIMEHeaderKey(name) == proxyWSBackendAuthorizationHeader { + copied := append([]string(nil), values...) + filtered["Authorization"] = copied + continue + } + if !allowProxyWSHeader(name) { + continue + } + copied := append([]string(nil), values...) + filtered[textproto.CanonicalMIMEHeaderKey(name)] = copied + } + return filtered +} + +func allowProxyWSHeader(name string) bool { + canonical := textproto.CanonicalMIMEHeaderKey(name) + if canonical == "Connection" || canonical == "Upgrade" || canonical == "Origin" || canonical == "User-Agent" { + return true + } + return canonical == "Sec-Websocket-Key" || canonical == "Sec-Websocket-Version" || canonical == "Sec-Websocket-Protocol" || canonical == "Sec-Websocket-Extensions" +} + +func SetProxyWSBackendAuthorization(headers http.Header, value string) { + if headers == nil { + return + } + value = textproto.TrimString(value) + if value == "" { + headers.Del(proxyWSBackendAuthorizationHeader) + return + } + headers.Set(proxyWSBackendAuthorizationHeader, value) +}
internal/handlers/proxy_ws_test.go+72 −0 added@@ -0,0 +1,72 @@ +package handlers + +import ( + "net/http" + "testing" +) + +func TestFilterProxyWSHeaders_StripsSensitiveHeaders(t *testing.T) { + headers := http.Header{ + "Connection": {"Upgrade"}, + "Upgrade": {"websocket"}, + "Sec-WebSocket-Key": {"abc123"}, + "Sec-WebSocket-Version": {"13"}, + "Sec-WebSocket-Protocol": {"chat"}, + "Sec-WebSocket-Extensions": {"permessage-deflate"}, + "Authorization": {"Bearer secret-token"}, + "Cookie": {"pinchtab_auth_token=session-secret"}, + "X-Forwarded-For": {"203.0.113.1"}, + "X-Request-Id": {"request-123"}, + } + + filtered := filterProxyWSHeaders(headers) + + for _, forbidden := range []string{"Authorization", "Cookie", "X-Forwarded-For", "X-Request-Id"} { + if got := filtered.Get(forbidden); got != "" { + t.Fatalf("%s should have been stripped, got %q", forbidden, got) + } + } + for _, allowed := range []string{"Connection", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Version", "Sec-WebSocket-Protocol", "Sec-WebSocket-Extensions"} { + if got := filtered.Get(allowed); got == "" { + t.Fatalf("%s should have been forwarded", allowed) + } + } +} + +func TestFilterProxyWSHeaders_ForwardsExplicitBackendAuthorizationOnly(t *testing.T) { + headers := http.Header{ + "Authorization": {"Bearer user-token"}, + proxyWSBackendAuthorizationHeader: {"Bearer backend-token"}, + } + + filtered := filterProxyWSHeaders(headers) + + if got := filtered.Get("Authorization"); got != "Bearer backend-token" { + t.Fatalf("Authorization = %q, want backend token", got) + } + if got := filtered.Get(proxyWSBackendAuthorizationHeader); got != "" { + t.Fatalf("%s should not be forwarded directly, got %q", proxyWSBackendAuthorizationHeader, got) + } +} + +func TestAllowProxyWSHeader_OriginAndUserAgentAllowed(t *testing.T) { + for _, name := range []string{"Origin", "User-Agent"} { + if !allowProxyWSHeader(name) { + t.Fatalf("%s should be allowed", name) + } + } +} + +func TestSetProxyWSBackendAuthorization(t *testing.T) { + headers := http.Header{} + + SetProxyWSBackendAuthorization(headers, "Bearer backend-token") + if got := headers.Get(proxyWSBackendAuthorizationHeader); got != "Bearer backend-token" { + t.Fatalf("proxy auth header = %q, want backend token", got) + } + + SetProxyWSBackendAuthorization(headers, "") + if got := headers.Get(proxyWSBackendAuthorizationHeader); got != "" { + t.Fatalf("proxy auth header should be cleared, got %q", got) + } +}
internal/handlers/wait.go+6 −0 modified@@ -139,6 +139,12 @@ func (h *Handlers) handleWaitCore(w http.ResponseWriter, r *http.Request, req wa httpx.Error(w, 400, fmt.Errorf("one of selector, text, url, load, fn, or ms is required")) return } + if mode == "fn" && !h.evaluateEnabled() { + httpx.ErrorCode(w, 403, "evaluate_disabled", httpx.DisabledEndpointMessage("evaluate", "security.allowEvaluate"), false, map[string]any{ + "setting": "security.allowEvaluate", + }) + return + } // Fixed duration wait doesn't need a browser tab. if mode == "ms" {
internal/handlers/wait_test.go+24 −1 modified@@ -187,7 +187,7 @@ func TestHandleWait_URLNoTab(t *testing.T) { } func TestHandleWait_FnNoTab(t *testing.T) { - h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{}, nil, nil, nil) + h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{AllowEvaluate: true}, nil, nil, nil) req := httptest.NewRequest("POST", "/wait", bytes.NewReader([]byte(`{"fn":"document.readyState === 'complete'"}`))) w := httptest.NewRecorder() h.HandleWait(w, req) @@ -196,6 +196,29 @@ func TestHandleWait_FnNoTab(t *testing.T) { } } +func TestHandleWait_FnBlockedWhenEvaluateDisabled(t *testing.T) { + h := New(&mockBridge{}, &config.RuntimeConfig{AllowEvaluate: false}, nil, nil, nil) + req := httptest.NewRequest("POST", "/wait", bytes.NewReader([]byte(`{"fn":"true"}`))) + w := httptest.NewRecorder() + h.HandleWait(w, req) + if w.Code != 403 { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte("evaluate_disabled")) { + t.Fatalf("expected evaluate_disabled response, got %s", w.Body.String()) + } +} + +func TestHandleWait_SelectorNotBlockedByEvaluateSetting(t *testing.T) { + h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{AllowEvaluate: false}, nil, nil, nil) + req := httptest.NewRequest("POST", "/wait", bytes.NewReader([]byte(`{"selector":"#results"}`))) + w := httptest.NewRecorder() + h.HandleWait(w, req) + if w.Code != 404 { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + func TestHandleWait_LoadNoTab(t *testing.T) { h := New(&mockBridge{failTab: true}, &config.RuntimeConfig{}, nil, nil, nil) req := httptest.NewRequest("POST", "/wait", bytes.NewReader([]byte(`{"load":"networkidle"}`)))
internal/idpi/domain.go+28 −8 modified@@ -37,20 +37,27 @@ func CheckDomain(rawURL string, cfg config.IDPIConfig) CheckResult { "URL has no domain component and cannot be verified against allowedDomains") } - for _, pattern := range cfg.AllowedDomains { - pattern = strings.ToLower(strings.TrimSpace(pattern)) - if pattern == "" { - continue - } - if matchDomain(host, pattern) { - return CheckResult{} - } + if domainAllowed(host, cfg.AllowedDomains) { + return CheckResult{} } return makeResult(cfg.StrictMode, fmt.Sprintf("domain %q is not in the allowed list (security.idpi.allowedDomains)", host)) } +// DomainAllowed reports whether rawURL's host matches an explicit allowedDomains +// entry under an active IDPI domain allowlist. +func DomainAllowed(rawURL string, cfg config.IDPIConfig) bool { + if !cfg.Enabled || len(cfg.AllowedDomains) == 0 || isAllowedSpecialURL(rawURL) { + return false + } + host := extractHost(rawURL) + if host == "" { + return false + } + return domainAllowed(host, cfg.AllowedDomains) +} + func isAllowedSpecialURL(rawURL string) bool { return strings.EqualFold(strings.TrimSpace(rawURL), "about:blank") } @@ -83,6 +90,19 @@ func extractHost(rawURL string) string { return strings.ToLower(strings.TrimSpace(host)) } +func domainAllowed(host string, patterns []string) bool { + for _, pattern := range patterns { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + if pattern == "" { + continue + } + if matchDomain(host, pattern) { + return true + } + } + return false +} + // matchDomain reports whether host matches pattern (both already lowercased). func matchDomain(host, pattern string) bool { switch {
internal/idpi/idpi_test.go+22 −0 modified@@ -214,6 +214,28 @@ func TestCheckDomain_EmptyListAllowsNoHost(t *testing.T) { } } +func TestDomainAllowed(t *testing.T) { + cfg := enabledCfg(func(c *config.IDPIConfig) { + c.AllowedDomains = []string{"fixtures", "*.example.com"} + }) + + if !DomainAllowed("http://fixtures:80/index.html", cfg) { + t.Fatal("expected fixtures to match explicit allowlist") + } + if !DomainAllowed("https://api.example.com", cfg) { + t.Fatal("expected wildcard subdomain to match explicit allowlist") + } + if DomainAllowed("https://evil.com", cfg) { + t.Fatal("unexpected allowlist match for evil.com") + } + if DomainAllowed("about:blank", cfg) { + t.Fatal("special URLs should not count as explicit allowlist matches") + } + if DomainAllowed("http://fixtures:80/index.html", config.IDPIConfig{}) { + t.Fatal("disabled/empty IDPI config should not report explicit allowlist matches") + } +} + // ─── ScanContent ────────────────────────────────────────────────────────────── func TestScanContent_DisabledAlwaysPasses(t *testing.T) {
internal/netguard/netguard.go+140 −0 added@@ -0,0 +1,140 @@ +package netguard + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strings" +) + +var ( + ErrResolveHost = errors.New("could not resolve host") + ErrPrivateInternalIP = errors.New("private/internal IP blocked") + ErrUnparseableRemoteIP = errors.New("unparseable remote IP") +) + +var ResolveHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + return net.DefaultResolver.LookupIP(ctx, network, host) +} + +var blockedPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("198.18.0.0/15"), +} + +func NormalizeHost(host string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") +} + +func IsLocalHost(host string) bool { + host = NormalizeHost(host) + if host == "" { + return false + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + return addr.Unmap().IsLoopback() +} + +func ValidatePublicIP(ip net.IP) error { + if ip == nil { + return ErrPrivateInternalIP + } + + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return ErrPrivateInternalIP + } + addr = addr.Unmap() + if addr.IsPrivate() || + addr.IsLoopback() || + addr.IsLinkLocalUnicast() || + addr.IsLinkLocalMulticast() || + addr.IsInterfaceLocalMulticast() || + addr.IsMulticast() || + addr.IsUnspecified() { + return ErrPrivateInternalIP + } + for _, prefix := range blockedPrefixes { + if prefix.Contains(addr) { + return ErrPrivateInternalIP + } + } + return nil +} + +func ResolveAndValidatePublicIPs(ctx context.Context, host string) ([]netip.Addr, error) { + host = NormalizeHost(host) + if host == "" { + return nil, ErrResolveHost + } + + if ip := net.ParseIP(host); ip != nil { + addr, err := publicAddr(ip) + if err != nil { + return nil, err + } + return []netip.Addr{addr}, nil + } + + ips, err := ResolveHostIPs(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, ErrResolveHost + } + + seen := make(map[netip.Addr]struct{}, len(ips)) + out := make([]netip.Addr, 0, len(ips)) + for _, ip := range ips { + addr, err := publicAddr(ip) + if err != nil { + return nil, err + } + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + out = append(out, addr) + } + if len(out) == 0 { + return nil, ErrResolveHost + } + return out, nil +} + +func NormalizeRemoteIP(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "[") + raw = strings.TrimSuffix(raw, "]") + return raw +} + +func ValidateRemoteIPAddress(raw string) error { + raw = NormalizeRemoteIP(raw) + if raw == "" { + return nil + } + ip := net.ParseIP(raw) + if ip == nil { + return fmt.Errorf("%w %q", ErrUnparseableRemoteIP, raw) + } + return ValidatePublicIP(ip) +} + +func publicAddr(ip net.IP) (netip.Addr, error) { + if err := ValidatePublicIP(ip); err != nil { + return netip.Addr{}, err + } + addr, _ := netip.AddrFromSlice(ip) + return addr.Unmap(), nil +}
internal/netguard/netguard_test.go+137 −0 added@@ -0,0 +1,137 @@ +package netguard + +import ( + "context" + "errors" + "net" + "testing" +) + +func stubResolveHostIPs(t *testing.T, fn func(context.Context, string, string) ([]net.IP, error)) { + t.Helper() + old := ResolveHostIPs + ResolveHostIPs = fn + t.Cleanup(func() { + ResolveHostIPs = old + }) +} + +func TestIsLocalHost(t *testing.T) { + tests := []struct { + host string + want bool + }{ + {host: "localhost", want: true}, + {host: "LOCALHOST.", want: true}, + {host: "api.localhost", want: true}, + {host: "127.0.0.1", want: true}, + {host: "::1", want: true}, + {host: "::ffff:127.0.0.1", want: true}, + {host: "93.184.216.34", want: false}, + {host: "example.com", want: false}, + } + + for _, tt := range tests { + if got := IsLocalHost(tt.host); got != tt.want { + t.Fatalf("IsLocalHost(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} + +func TestValidatePublicIP(t *testing.T) { + tests := []struct { + name string + rawIP string + wantErr bool + }{ + {name: "public ipv4", rawIP: "93.184.216.34", wantErr: false}, + {name: "public ipv6", rawIP: "2606:2800:220:1:248:1893:25c8:1946", wantErr: false}, + {name: "private ipv4", rawIP: "192.168.1.10", wantErr: true}, + {name: "loopback ipv4", rawIP: "127.0.0.1", wantErr: true}, + {name: "loopback mapped ipv6", rawIP: "::ffff:127.0.0.1", wantErr: true}, + {name: "shared address space", rawIP: "100.64.0.10", wantErr: true}, + {name: "benchmark network", rawIP: "198.18.0.10", wantErr: true}, + {name: "metadata link-local", rawIP: "169.254.169.254", wantErr: true}, + } + + for _, tt := range tests { + err := ValidatePublicIP(net.ParseIP(tt.rawIP)) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidatePublicIP(%q) error = %v, wantErr %v", tt.rawIP, err, tt.wantErr) + } + if tt.wantErr && !errors.Is(err, ErrPrivateInternalIP) { + t.Fatalf("ValidatePublicIP(%q) error = %v, want private/internal sentinel", tt.rawIP, err) + } + } +} + +func TestResolveAndValidatePublicIPs(t *testing.T) { + stubResolveHostIPs(t, func(ctx context.Context, network, host string) ([]net.IP, error) { + switch host { + case "public.example": + return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("2606:2800:220:1:248:1893:25c8:1946")}, nil + case "mixed.example": + return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("10.0.0.8")}, nil + case "dupe.example": + return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("93.184.216.34")}, nil + default: + return nil, errors.New("not found") + } + }) + + ips, err := ResolveAndValidatePublicIPs(context.Background(), "public.example") + if err != nil { + t.Fatalf("ResolveAndValidatePublicIPs(public.example) error = %v", err) + } + if len(ips) != 2 { + t.Fatalf("ResolveAndValidatePublicIPs(public.example) len = %d, want 2", len(ips)) + } + + ips, err = ResolveAndValidatePublicIPs(context.Background(), "dupe.example") + if err != nil { + t.Fatalf("ResolveAndValidatePublicIPs(dupe.example) error = %v", err) + } + if len(ips) != 1 { + t.Fatalf("ResolveAndValidatePublicIPs(dupe.example) len = %d, want 1", len(ips)) + } + + if _, err := ResolveAndValidatePublicIPs(context.Background(), "mixed.example"); !errors.Is(err, ErrPrivateInternalIP) { + t.Fatalf("ResolveAndValidatePublicIPs(mixed.example) error = %v, want private/internal sentinel", err) + } + + if _, err := ResolveAndValidatePublicIPs(context.Background(), "missing.example"); !errors.Is(err, ErrResolveHost) { + t.Fatalf("ResolveAndValidatePublicIPs(missing.example) error = %v, want resolve sentinel", err) + } + + if _, err := ResolveAndValidatePublicIPs(context.Background(), "127.0.0.1"); !errors.Is(err, ErrPrivateInternalIP) { + t.Fatalf("ResolveAndValidatePublicIPs(127.0.0.1) error = %v, want private/internal sentinel", err) + } +} + +func TestValidateRemoteIPAddress(t *testing.T) { + tests := []struct { + name string + raw string + wantErr error + }{ + {name: "empty", raw: "", wantErr: nil}, + {name: "public ipv4", raw: "93.184.216.34", wantErr: nil}, + {name: "bracketed public ipv6", raw: "[2606:2800:220:1:248:1893:25c8:1946]", wantErr: nil}, + {name: "loopback", raw: "127.0.0.1", wantErr: ErrPrivateInternalIP}, + {name: "mapped loopback", raw: "::ffff:127.0.0.1", wantErr: ErrPrivateInternalIP}, + {name: "garbage", raw: "not-an-ip", wantErr: ErrUnparseableRemoteIP}, + } + + for _, tt := range tests { + err := ValidateRemoteIPAddress(tt.raw) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ValidateRemoteIPAddress(%q) error = %v, want nil", tt.raw, err) + } + continue + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateRemoteIPAddress(%q) error = %v, want %v", tt.raw, err, tt.wantErr) + } + } +}
internal/orchestrator/handlers.go+1 −0 modified@@ -77,6 +77,7 @@ func (o *Orchestrator) registerHandlers(mux *http.ServeMux, skipLaunch bool) { "POST /tabs/{id}/back", "POST /tabs/{id}/forward", "POST /tabs/{id}/reload", + "POST /tabs/{id}/wait", } { mux.HandleFunc(route, o.proxyTabRequest) }
internal/orchestrator/orchestrator.go+46 −5 modified@@ -82,6 +82,7 @@ type InstanceInternal struct { Error string authToken string + cdpPort int cmd Cmd logBuf *ringBuffer } @@ -243,6 +244,13 @@ func (o *Orchestrator) Launch(name, port string, headless bool, extensionPaths [ if err := profiles.ValidateProfileName(name); err != nil { return nil, err } + reservedPorts := make([]int, 0, 2) + defer func() { + for _, reserved := range reservedPorts { + o.portAllocator.ReleasePort(reserved) + } + }() + o.mu.Lock() if port == "" || port == "0" { @@ -252,6 +260,18 @@ func (o *Orchestrator) Launch(name, port string, headless bool, extensionPaths [ return nil, fmt.Errorf("failed to allocate port: %w", err) } port = fmt.Sprintf("%d", allocatedPort) + reservedPorts = append(reservedPorts, allocatedPort) + o.mu.Lock() + } else { + o.mu.Unlock() + if portInt, err := strconv.Atoi(port); err == nil { + if err := o.portAllocator.ReservePort(portInt); err != nil { + return nil, fmt.Errorf("failed to reserve port %s: %w", port, err) + } + if portInt >= o.portAllocator.start && portInt <= o.portAllocator.end { + reservedPorts = append(reservedPorts, portInt) + } + } o.mu.Lock() } @@ -280,6 +300,12 @@ func (o *Orchestrator) Launch(name, port string, headless bool, extensionPaths [ o.mu.Unlock() + cdpPort, err := o.portAllocator.AllocatePort() + if err != nil { + return nil, fmt.Errorf("failed to allocate chrome debug port: %w", err) + } + reservedPorts = append(reservedPorts, cdpPort) + profilePath := filepath.Join(o.baseDir, name) if o.profiles != nil { if resolvedPath, err := o.profiles.ProfilePath(name); err == nil { @@ -294,7 +320,7 @@ func (o *Orchestrator) Launch(name, port string, headless bool, extensionPaths [ return nil, fmt.Errorf("create state dir: %w", err) } - childConfigPath, err := o.writeChildConfig(port, profilePath, instanceStateDir, headless, extensionPaths) + childConfigPath, err := o.writeChildConfig(port, cdpPort, profilePath, instanceStateDir, headless, extensionPaths) if err != nil { return nil, fmt.Errorf("write child config: %w", err) } @@ -324,24 +350,27 @@ func (o *Orchestrator) Launch(name, port string, headless bool, extensionPaths [ Status: "starting", StartTime: time.Now(), }, - URL: fmt.Sprintf("http://localhost:%s", port), - cmd: cmd, - logBuf: logBuf, + URL: fmt.Sprintf("http://localhost:%s", port), + cdpPort: cdpPort, + cmd: cmd, + logBuf: logBuf, } o.mu.Lock() o.instances[instanceID] = inst o.mu.Unlock() + reservedPorts = nil go o.monitor(inst) return &inst.Instance, nil } -func (o *Orchestrator) writeChildConfig(port, profilePath, instanceStateDir string, headless bool, extensionPaths []string) (string, error) { +func (o *Orchestrator) writeChildConfig(port string, cdpPort int, profilePath, instanceStateDir string, headless bool, extensionPaths []string) (string, error) { fc := config.FileConfigFromRuntime(o.runtimeCfg) fc.Server.Port = port fc.Server.StateDir = instanceStateDir + fc.Browser.ChromeDebugPort = intPtr(cdpPort) fc.Profiles.BaseDir = filepath.Dir(profilePath) fc.Profiles.DefaultProfile = filepath.Base(profilePath) if headless { @@ -381,6 +410,14 @@ func (o *Orchestrator) writeChildConfig(port, profilePath, instanceStateDir stri return configPath, nil } +func intPtr(v int) *int { + if v <= 0 { + return nil + } + n := v + return &n +} + func (o *Orchestrator) attachExternalInstance(name string, inst bridge.Instance, authToken string) (*bridge.Instance, error) { o.mu.Lock() for _, inst := range o.instances { @@ -567,6 +604,10 @@ func (o *Orchestrator) markStopped(id string) { o.portAllocator.ReleasePort(portInt) slog.Debug("released port", "id", id, "port", portStr) } + if inst.cdpPort > 0 { + o.portAllocator.ReleasePort(inst.cdpPort) + slog.Debug("released chrome debug port", "id", id, "port", inst.cdpPort) + } profileName := inst.ProfileName delete(o.instances, id)
internal/orchestrator/orchestrator_test.go+150 −0 modified@@ -2,19 +2,42 @@ package orchestrator import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" "github.com/pinchtab/pinchtab/internal/bridge" "github.com/pinchtab/pinchtab/internal/config" ) +func envMap(items []string) map[string]string { + out := make(map[string]string, len(items)) + for _, item := range items { + key, value, ok := strings.Cut(item, "=") + if ok { + out[key] = value + } + } + return out +} + +func stubPortAvailability(t *testing.T, fn func(int) bool) { + t.Helper() + old := portAvailableFunc + portAvailableFunc = fn + t.Cleanup(func() { + portAvailableFunc = old + }) +} + func TestOrchestrator_Launch_Lifecycle(t *testing.T) { old := processAliveFunc processAliveFunc = func(pid int) bool { return pid > 0 } defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) runner := &mockRunner{portAvail: true} o := NewOrchestratorWithRunner(t.TempDir(), runner) @@ -44,6 +67,7 @@ func TestOrchestrator_ListAndStop(t *testing.T) { old := processAliveFunc processAliveFunc = func(pid int) bool { return alive } defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) runner := &mockRunner{portAvail: true} o := NewOrchestratorWithRunner(t.TempDir(), runner) @@ -107,6 +131,7 @@ func TestOrchestrator_Launch_RejectsPathTraversal(t *testing.T) { old := processAliveFunc processAliveFunc = func(pid int) bool { return pid > 0 } defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) runner := &mockRunner{portAvail: true} o := NewOrchestratorWithRunner(t.TempDir(), runner) @@ -143,6 +168,7 @@ func TestOrchestrator_Launch_AcceptsValidNames(t *testing.T) { old := processAliveFunc processAliveFunc = func(pid int) bool { return pid > 0 } defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) runner := &mockRunner{portAvail: true} o := NewOrchestratorWithRunner(t.TempDir(), runner) @@ -172,6 +198,130 @@ func TestOrchestrator_Launch_AcceptsValidNames(t *testing.T) { } } +func TestOrchestrator_Launch_ReservesDistinctChromeDebugPort(t *testing.T) { + old := processAliveFunc + processAliveFunc = func(pid int) bool { return pid > 0 } + defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) + + runner := &mockRunner{portAvail: true} + o := NewOrchestratorWithRunner(t.TempDir(), runner) + o.ApplyRuntimeConfig(&config.RuntimeConfig{ + Token: "child-token", + InstancePortStart: 9900, + InstancePortEnd: 9903, + }) + + inst, err := o.Launch("profile1", "", true, nil) + if err != nil { + t.Fatalf("Launch failed: %v", err) + } + + if inst.Port != "9900" { + t.Fatalf("bridge port = %s, want 9900", inst.Port) + } + + cfgPath := envMap(runner.env)["PINCHTAB_CONFIG"] + if cfgPath == "" { + t.Fatal("PINCHTAB_CONFIG missing from child env") + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", cfgPath, err) + } + + var fc config.FileConfig + if err := json.Unmarshal(data, &fc); err != nil { + t.Fatalf("Unmarshal child config error = %v", err) + } + if fc.Browser.ChromeDebugPort == nil { + t.Fatal("child config missing browser.remoteDebuggingPort") + } + if *fc.Browser.ChromeDebugPort != 9901 { + t.Fatalf("chrome debug port = %d, want 9901", *fc.Browser.ChromeDebugPort) + } + if *fc.Browser.ChromeDebugPort == 9900 { + t.Fatal("chrome debug port should differ from bridge port") + } + + gotPorts := o.portAllocator.AllocatedPorts() + if len(gotPorts) != 2 { + t.Fatalf("allocated ports = %v, want 2 reserved ports", gotPorts) + } + if !o.portAllocator.IsAllocated(9900) || !o.portAllocator.IsAllocated(9901) { + t.Fatalf("expected ports 9900 and 9901 reserved, got %v", gotPorts) + } +} + +func TestOrchestrator_Launch_ExplicitPortAlsoReservesDistinctChromeDebugPort(t *testing.T) { + old := processAliveFunc + processAliveFunc = func(pid int) bool { return pid > 0 } + defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) + + runner := &mockRunner{portAvail: true} + o := NewOrchestratorWithRunner(t.TempDir(), runner) + o.ApplyRuntimeConfig(&config.RuntimeConfig{ + InstancePortStart: 9910, + InstancePortEnd: 9913, + }) + + inst, err := o.Launch("profile1", "9911", true, nil) + if err != nil { + t.Fatalf("Launch failed: %v", err) + } + if inst.Port != "9911" { + t.Fatalf("bridge port = %s, want 9911", inst.Port) + } + + cfgPath := envMap(runner.env)["PINCHTAB_CONFIG"] + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", cfgPath, err) + } + + var fc config.FileConfig + if err := json.Unmarshal(data, &fc); err != nil { + t.Fatalf("Unmarshal child config error = %v", err) + } + if fc.Browser.ChromeDebugPort == nil { + t.Fatal("child config missing browser.remoteDebuggingPort") + } + if *fc.Browser.ChromeDebugPort == 9911 { + t.Fatalf("chrome debug port = %d, must differ from bridge port", *fc.Browser.ChromeDebugPort) + } + if !o.portAllocator.IsAllocated(9911) { + t.Fatal("explicit bridge port should remain reserved in allocator while instance is active") + } +} + +func TestOrchestrator_Stop_ReleasesBridgeAndChromeDebugPorts(t *testing.T) { + old := processAliveFunc + processAliveFunc = func(pid int) bool { return false } + defer func() { processAliveFunc = old }() + stubPortAvailability(t, func(int) bool { return true }) + + runner := &mockRunner{portAvail: true} + o := NewOrchestratorWithRunner(t.TempDir(), runner) + o.ApplyRuntimeConfig(&config.RuntimeConfig{ + InstancePortStart: 9920, + InstancePortEnd: 9923, + }) + + inst, err := o.Launch("profile1", "", true, nil) + if err != nil { + t.Fatalf("Launch failed: %v", err) + } + + if err := o.Stop(inst.ID); err != nil { + t.Fatalf("Stop failed: %v", err) + } + if got := o.portAllocator.AllocatedPorts(); len(got) != 0 { + t.Fatalf("allocated ports after stop = %v, want none", got) + } +} + func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
internal/orchestrator/ports.go+21 −1 modified@@ -7,6 +7,8 @@ import ( "sync" ) +var portAvailableFunc = isPortAvailableInt + type PortAllocator struct { mu sync.Mutex start int @@ -52,7 +54,7 @@ func (pa *PortAllocator) AllocatePort() (int, error) { continue } - if isPortAvailableInt(candidate) { + if portAvailableFunc(candidate) { pa.allocated[candidate] = true slog.Debug("allocated port", "port", candidate) return candidate, nil @@ -64,6 +66,24 @@ func (pa *PortAllocator) AllocatePort() (int, error) { return 0, fmt.Errorf("no available ports in range %d-%d", pa.start, pa.end) } +func (pa *PortAllocator) ReservePort(port int) error { + pa.mu.Lock() + defer pa.mu.Unlock() + + if port < pa.start || port > pa.end { + return nil + } + if pa.allocated[port] { + return fmt.Errorf("port %d already reserved", port) + } + if !portAvailableFunc(port) { + return fmt.Errorf("port %d is already in use", port) + } + pa.allocated[port] = true + slog.Debug("reserved port", "port", port) + return nil +} + func (pa *PortAllocator) ReleasePort(port int) { pa.mu.Lock() defer pa.mu.Unlock()
internal/orchestrator/process_test.go+14 −0 modified@@ -13,6 +13,8 @@ type mockRunner struct { runCalled bool portAvail bool args []string + env []string + runErr error } type mockCmd struct { @@ -27,6 +29,10 @@ func (m *mockCmd) Cancel() {} func (m *mockRunner) Run(ctx context.Context, binary string, args []string, env []string, stdout, stderr io.Writer) (Cmd, error) { m.runCalled = true m.args = append([]string(nil), args...) + m.env = append([]string(nil), env...) + if m.runErr != nil { + return nil, m.runErr + } return &mockCmd{pid: 1234, isAlive: true}, nil } @@ -35,6 +41,10 @@ func (m *mockRunner) IsPortAvailable(port string) bool { } func TestLaunch_Mocked(t *testing.T) { + old := portAvailableFunc + portAvailableFunc = func(int) bool { return true } + defer func() { portAvailableFunc = old }() + runner := &mockRunner{portAvail: true} o := NewOrchestratorWithRunner(t.TempDir(), runner) @@ -55,6 +65,10 @@ func TestLaunch_Mocked(t *testing.T) { } func TestLaunch_PortConflict(t *testing.T) { + old := portAvailableFunc + portAvailableFunc = func(int) bool { return true } + defer func() { portAvailableFunc = old }() + runner := &mockRunner{portAvail: false} o := NewOrchestratorWithRunner(t.TempDir(), runner)
internal/orchestrator/proxy.go+8 −1 modified@@ -182,7 +182,14 @@ func (o *Orchestrator) handleProxyScreencast(w http.ResponseWriter, r *http.Requ req := r.Clone(r.Context()) req.Header = r.Header.Clone() activity.PropagateHeaders(r.Context(), req) - o.applyInstanceAuth(req, inst) + req.Header.Del("Authorization") + req.Header.Del("Cookie") + handlers.SetProxyWSBackendAuthorization(req.Header, "") + if token := inst.authToken; token != "" { + handlers.SetProxyWSBackendAuthorization(req.Header, "Bearer "+token) + } else if token := o.childAuthToken; token != "" { + handlers.SetProxyWSBackendAuthorization(req.Header, "Bearer "+token) + } // Use WebSocket proxy for proper upgrade handlers.ProxyWebSocket(w, req, targetURL.String())
internal/scheduler/webhook_guard.go+11 −65 modified@@ -2,22 +2,15 @@ package scheduler import ( "context" + "errors" "fmt" - "net" "net/netip" "net/url" "strings" "time" -) - -var resolveWebhookHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { - return net.DefaultResolver.LookupIP(ctx, network, host) -} -var blockedWebhookPrefixes = []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("198.18.0.0/15"), -} + "github.com/pinchtab/pinchtab/internal/netguard" +) type callbackURLGuard struct{} @@ -50,8 +43,8 @@ func (g *callbackURLGuard) ValidateTarget(rawURL string) (*validatedCallbackTarg return nil, fmt.Errorf("callback URL credentials are not allowed") } - host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") - if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { + host := netguard.NormalizeHost(parsed.Hostname()) + if host == "" || netguard.IsLocalHost(host) { return nil, fmt.Errorf("callback URL host is not allowed") } port := parsed.Port() @@ -72,40 +65,20 @@ func (g *callbackURLGuard) ValidateTarget(rawURL string) (*validatedCallbackTarg Port: port, } - if ip := net.ParseIP(host); ip != nil { - addr, err := validateWebhookIP(ip) - if err != nil { - return nil, err - } - target.IPs = []netip.Addr{addr} - return target, nil - } - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() - ips, err := resolveWebhookHostIPs(ctx, "ip", host) + ips, err := netguard.ResolveAndValidatePublicIPs(ctx, host) if err != nil { - return nil, fmt.Errorf("could not resolve callback host") - } - if len(ips) == 0 { - return nil, fmt.Errorf("could not resolve callback host") - } - seen := make(map[netip.Addr]struct{}, len(ips)) - for _, ip := range ips { - addr, err := validateWebhookIP(ip) - if err != nil { - return nil, err + if errors.Is(err, netguard.ErrResolveHost) { + return nil, fmt.Errorf("could not resolve callback host") } - if _, ok := seen[addr]; ok { - continue + if errors.Is(err, netguard.ErrPrivateInternalIP) { + return nil, fmt.Errorf("callback URL host is not allowed") } - seen[addr] = struct{}{} - target.IPs = append(target.IPs, addr) - } - if len(target.IPs) == 0 { return nil, fmt.Errorf("could not resolve callback host") } + target.IPs = append(target.IPs, ips...) return target, nil } @@ -116,30 +89,3 @@ func validateCallbackURL(rawURL string) error { func validateCallbackTarget(rawURL string) (*validatedCallbackTarget, error) { return newCallbackURLGuard().ValidateTarget(rawURL) } - -func validateWebhookIP(ip net.IP) (netip.Addr, error) { - if ip == nil { - return netip.Addr{}, fmt.Errorf("callback URL host is not allowed") - } - - addr, ok := netip.AddrFromSlice(ip) - if !ok { - return netip.Addr{}, fmt.Errorf("callback URL host is not allowed") - } - addr = addr.Unmap() - if addr.IsPrivate() || - addr.IsLoopback() || - addr.IsLinkLocalUnicast() || - addr.IsLinkLocalMulticast() || - addr.IsInterfaceLocalMulticast() || - addr.IsMulticast() || - addr.IsUnspecified() { - return netip.Addr{}, fmt.Errorf("callback URL host is not allowed") - } - for _, prefix := range blockedWebhookPrefixes { - if prefix.Contains(addr) { - return netip.Addr{}, fmt.Errorf("callback URL host is not allowed") - } - } - return addr, nil -}
internal/scheduler/webhook_test.go+8 −6 modified@@ -12,6 +12,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/pinchtab/pinchtab/internal/netguard" ) func configureWebhookTestTarget(t *testing.T, serverURL string, timeout time.Duration) (string, *atomic.Value, func()) { @@ -29,8 +31,8 @@ func configureWebhookTestTarget(t *testing.T, serverURL string, timeout time.Dur } callbackURL += parsed.Path - origResolver := resolveWebhookHostIPs - resolveWebhookHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + origResolver := netguard.ResolveHostIPs + netguard.ResolveHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { if strings.EqualFold(host, "callback.example") { return []net.IP{net.ParseIP("93.184.216.34")}, nil } @@ -56,7 +58,7 @@ func configureWebhookTestTarget(t *testing.T, serverURL string, timeout time.Dur cleanup := func() { dialWebhookAddress = origDial webhookHTTPTimeout = origTimeout - resolveWebhookHostIPs = origResolver + netguard.ResolveHostIPs = origResolver } return callbackURL, dialedAddr, cleanup } @@ -143,11 +145,11 @@ func TestSendWebhookRejectedHost(t *testing.T) { } func TestValidateCallbackURLRejectsResolvedBlockedHost(t *testing.T) { - origResolver := resolveWebhookHostIPs - resolveWebhookHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { + origResolver := netguard.ResolveHostIPs + netguard.ResolveHostIPs = func(ctx context.Context, network, host string) ([]net.IP, error) { return []net.IP{net.ParseIP("10.0.0.8")}, nil } - defer func() { resolveWebhookHostIPs = origResolver }() + defer func() { netguard.ResolveHostIPs = origResolver }() if err := validateCallbackURL("http://callback.example/hook"); err == nil { t.Fatal("expected resolved private host to be rejected")
internal/server/bridge.go+3 −1 modified@@ -66,7 +66,9 @@ func RunBridgeServer(cfg *config.RuntimeConfig) { activity.Middleware( actStore, "bridge", - handlers.LoggingMiddleware(handlers.RateLimitMiddleware(handlers.AuthMiddleware(cfg, mux))), + handlers.SecurityHeadersMiddleware(cfg, + handlers.LoggingMiddleware(handlers.RateLimitMiddleware(handlers.AuthMiddleware(cfg, mux))), + ), ), ), ReadHeaderTimeout: 10 * time.Second,
internal/server/server.go+3 −1 modified@@ -187,7 +187,9 @@ func RunDashboard(cfg *config.RuntimeConfig, version string) { activity.Middleware( actStore, "server", - handlers.LoggingMiddleware(handlers.RateLimitMiddleware(handlers.CorsMiddleware(cfg, handlers.AuthMiddlewareWithSessions(cfg, sessions, mux)))), + handlers.SecurityHeadersMiddleware(cfg, + handlers.LoggingMiddleware(handlers.RateLimitMiddleware(handlers.CorsMiddleware(cfg, handlers.AuthMiddlewareWithSessions(cfg, sessions, mux)))), + ), ), ) cli.LogSecurityWarnings(cfg)
internal/strategy/explicit/explicit.go+1 −0 modified@@ -47,6 +47,7 @@ func (s *Strategy) RegisterRoutes(mux *http.ServeMux) { "GET /errors", "POST /errors/clear", "POST /navigate", "POST /back", "POST /forward", "POST /reload", "POST /action", "POST /actions", + "POST /wait", "POST /tab", "POST /tab/lock", "POST /tab/unlock", "GET /cookies", "POST /cookies", "GET /stealth/status", "POST /fingerprint/rotate",
internal/strategy/noinstance/noinstance.go+1 −0 modified@@ -43,6 +43,7 @@ func (s *Strategy) RegisterRoutes(mux *http.ServeMux) { "GET /snapshot", "GET /screenshot", "GET /text", "GET /pdf", "POST /pdf", "POST /navigate", "POST /back", "POST /forward", "POST /reload", "POST /action", "POST /actions", + "POST /wait", "POST /tab", "POST /tab/lock", "POST /tab/unlock", "GET /cookies", "POST /cookies", "GET /stealth/status", "POST /fingerprint/rotate",
tests/e2e/config/pinchtab-lite.json+12 −1 modified@@ -8,6 +8,17 @@ "security": { "allowEvaluate": false, "allowDownload": false, - "allowUpload": false + "allowUpload": false, + "idpi": { + "enabled": true, + "strictMode": false, + "scanContent": true, + "allowedDomains": [ + "localhost", + "127.0.0.1", + "::1", + "fixtures" + ] + } } }
tests/e2e/scenarios-api/clipboard-basic.sh+2 −3 modified@@ -54,11 +54,10 @@ assert_not_ok "rejects missing text" end_test # ───────────────────────────────────────────────────────────────── -start_test "Clipboard ignores tabId for compatibility" +start_test "Clipboard rejects tabId" pt_get "/clipboard/read?tabId=nonexistent_xyz_999" -assert_ok "read ignores tabId" -assert_json_contains "$RESULT" '.text' "$COPY_TEXT" "compat tabId does not affect shared clipboard" +assert_not_ok "read rejects tabId" end_test
tests/e2e/scenarios-api/security-full.sh+13 −0 modified@@ -31,6 +31,19 @@ assert_contains "$RESULT" "evaluate_disabled" "correct error code" end_test +# ───────────────────────────────────────────────────────────────── +start_test "security: wait fn BLOCKED when evaluate disabled" + +secure_post /navigate -d '{"url":"http://fixtures:80/index.html"}' +assert_ok "navigate to allowed fixture page" +TAB_ID=$(echo "$RESULT" | jq -r '.tabId') + +secure_post /wait -d "{\"tabId\":\"${TAB_ID}\",\"fn\":\"true\",\"timeout\":1000}" +assert_http_status 403 "wait fn blocked" +assert_contains "$RESULT" "evaluate_disabled" "wait fn uses evaluate_disabled guard" + +end_test + # ───────────────────────────────────────────────────────────────── start_test "security: download BLOCKED when disabled"
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/pinchtab/pinchtab/commit/c619c43a4f29d1d1a481e859c193baf78e0d648bnvdPatchWEB
- github.com/pinchtab/pinchtab/security/advisories/GHSA-j65m-hv65-r264nvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-j65m-hv65-r264ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33621ghsaADVISORY
- github.com/pinchtab/pinchtab/releases/tag/v0.8.4nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.