diff --git a/docs/code-patterns.md b/docs/code-patterns.md index 8a0f9de..1c21ad6 100644 --- a/docs/code-patterns.md +++ b/docs/code-patterns.md @@ -15,6 +15,8 @@ Worth knowing before touching related code: +- The automation session's document CSP (`internal/browser/automation.go`, `restrictDocument`) only works because of two Chrome details. A document is paused again at the response stage only if its `ContinueRequest` sets `InterceptResponse`. And Chrome applies just the `sandbox` part of a CSP header edited with `Fetch.continueResponse`: `worker-src` and `connect-src` are ignored, so the response is fulfilled with its own body and the new header instead. Before this was found the policy never ran at all. `TestInjectedCSPApplies` runs a real page against it. +- Screenshots are named by their actual format: Chrome returns JPEG below quality 100 and PNG at 100, and they used to be JPEG data in `.png` files. - `history.Store.UpdatePipelineStatus` only moves a request forward (ranks in `internal/history/pipeline_rank.go`): an earlier or unknown stage is silently ignored, so a late "awaiting response" can't undo a confirmation. A person's explicit decision in the UI goes through `OverridePipelineStatus`, which always applies. Both record who made the change in `pipeline_actor`. - `batch_approvals` timestamps are stored and compared in UTC. SQLite compares them as text, so a local UTC offset (DST) would break the ordering. `ConsumeBatchApproval` is the only way to spend an approval and it is atomic: one approval, one batch. - `no_data_held` is a value in the export JSON (`response_type`, `pipeline_status`), so it can only be added to, never renamed. `MigrateNoData` only relabels saved replies; resolving a request is `ResolveNoData`'s job (and the reclassify action's). diff --git a/internal/browser/automation.go b/internal/browser/automation.go index b2f2adb..bcd86aa 100644 --- a/internal/browser/automation.go +++ b/internal/browser/automation.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "net" @@ -216,12 +217,7 @@ func (s *AutomationSession) Open(caller context.Context, raw string) (*Snapshot, case *fetch.EventRequestPaused: go s.checkRequest(e) case *network.EventResponseReceived: - // Our DNS lookup and Chrome's can differ, so check the address Chrome used. - if e.Response != nil && s.policy.LocalOrigin == "" && !publicAddress(e.Response.RemoteIPAddress) { - s.mu.Lock() - s.networkBlocked = "The page connected to a private network address, so it was blocked. Review the broker's URLs." - s.mu.Unlock() - } + s.noteResponse(e) } }) ctx, cancel := s.operation(caller) @@ -233,18 +229,63 @@ func (s *AutomationSession) Open(caller context.Context, raw string) (*Snapshot, return s.Observe(caller) } +// csp is added to every document the session loads. It keeps popups, workers +// and out-of-scope connections from escaping the tab's request interceptor; +// policies stack, so the site's own stays in force. +func (p URLPolicy) csp() string { + return "sandbox allow-scripts allow-same-origin allow-forms allow-modals; worker-src 'none'; connect-src " + strings.Join(p.connectSources(), " ") +} + +// restrictDocument adds the CSP to a document response. Chrome only enforces the +// sandbox part of a header changed through ContinueResponse and ignores +// worker-src and connect-src from it, so the response is fulfilled with its own +// body and the new header instead, which makes the whole policy count. +func (s *AutomationSession) restrictDocument(ctx context.Context, e *fetch.EventRequestPaused) { + headers := append(e.ResponseHeaders, &fetch.HeaderEntry{Name: "Content-Security-Policy", Value: s.policy.csp()}) + var err error + if e.ResponseStatusCode >= 300 && e.ResponseStatusCode < 400 { + // A redirect has no page; the next hop comes back through the interceptor. + if err := fetch.ContinueResponse(e.RequestID).Do(ctx); err != nil { + _ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(ctx) + } + return + } + body, bodyErr := fetch.GetResponseBody(e.RequestID).Do(ctx) + switch { + case bodyErr != nil: + // Nothing to read (an empty response): the sandbox still applies. + err = fetch.ContinueResponse(e.RequestID).WithResponseCode(e.ResponseStatusCode).WithResponseHeaders(headers).Do(ctx) + default: + // GetResponseBody returns the decoded body, so drop the encoding headers. + kept := make([]*fetch.HeaderEntry, 0, len(headers)) + for _, h := range headers { + if n := strings.ToLower(h.Name); n != "content-encoding" && n != "content-length" { + kept = append(kept, h) + } + } + err = fetch.FulfillRequest(e.RequestID, e.ResponseStatusCode).WithResponseHeaders(kept).WithBody(base64.StdEncoding.EncodeToString(body)).Do(ctx) + } + if err != nil { + _ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(ctx) + } +} + +// noteResponse stops the session when Chrome connected to a non-public address. +// Our DNS lookup and Chrome's can differ, so check the address Chrome used. +func (s *AutomationSession) noteResponse(e *network.EventResponseReceived) { + if e.Response != nil && s.policy.LocalOrigin == "" && !publicAddress(e.Response.RemoteIPAddress) { + s.mu.Lock() + s.networkBlocked = "The page connected to a private network address, so it was blocked. Review the broker's URLs." + s.mu.Unlock() + } +} + func (s *AutomationSession) checkRequest(e *fetch.EventRequestPaused) { ctx, cancel := context.WithTimeout(s.b.ctx, 10*time.Second) defer cancel() execCtx := cdp.WithExecutor(ctx, chromedp.FromContext(ctx).Target) if e.ResponseStatusCode != 0 { - // popups, workers and stray WebSockets could escape the request interceptor, so add - // a CSP; policies stack, so the site's own stays in force - csp := "sandbox allow-scripts allow-same-origin allow-forms allow-modals; worker-src 'none'; connect-src " + strings.Join(s.policy.connectSources(), " ") - headers := append(e.ResponseHeaders, &fetch.HeaderEntry{Name: "Content-Security-Policy", Value: csp}) - if err := fetch.ContinueResponse(e.RequestID).WithResponseCode(e.ResponseStatusCode).WithResponseHeaders(headers).Do(execCtx); err != nil { - _ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx) - } + s.restrictDocument(execCtx, e) return } // every request: redirects, frames, XHR, form actions. third-party portals must be in the catalog @@ -259,7 +300,13 @@ func (s *AutomationSession) checkRequest(e *fetch.EventRequestPaused) { _ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx) return } - _ = fetch.ContinueRequest(e.RequestID).Do(execCtx) + // Chrome only pauses a document again at the response stage, where the CSP + // is added, when the request asks for it. + cont := fetch.ContinueRequest(e.RequestID) + if e.ResourceType == network.ResourceTypeDocument { + cont = cont.WithInterceptResponse(true) + } + _ = cont.Do(execCtx) } func (s *AutomationSession) evaluate(ctx context.Context, expression string, out any) error { diff --git a/internal/browser/automation_extra_test.go b/internal/browser/automation_extra_test.go new file mode 100644 index 0000000..187384c --- /dev/null +++ b/internal/browser/automation_extra_test.go @@ -0,0 +1,249 @@ +package browser + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/chromedp/cdproto/network" +) + +// --- no Chrome needed --------------------------------------------------- + +func TestNewAutomationSessionNeedsApprovedDomains(t *testing.T) { + if _, err := NewAutomationSession(DefaultConfig(), testProfile(), URLPolicy{}); err == nil || !strings.Contains(err.Error(), "no approved domains") { + t.Fatalf("a broker with no domains: %v", err) + } + // Chrome starts lazily, so building and closing a session needs no browser. + s, err := NewAutomationSession(DefaultConfig(), testProfile(), NewURLPolicy("https://broker.example", "")) + if err != nil { + t.Fatal(err) + } + s.Close() +} + +func TestSnapshotFingerprintIgnoresEphemeralFields(t *testing.T) { + a := Snapshot{ID: "one", Screenshot: "a.png", URL: "https://x.example/f", Text: "Remove me"} + b := a + b.ID, b.Screenshot = "two", "b.png" + if a.Fingerprint() != b.Fingerprint() { + t.Error("re-observing the same page changed its fingerprint") + } + b.Text = "Thank you" + if a.Fingerprint() == b.Fingerprint() { + t.Error("a changed page kept its fingerprint") + } +} + +func TestPolicyEdgeCases(t *testing.T) { + if got := (URLPolicy{LocalOrigin: "http://127.0.0.1:9"}).connectSources(); got[len(got)-1] != "http://127.0.0.1:9" { + t.Errorf("connect sources = %v, want the local origin included", got) + } + err := NewURLPolicy("https://does-not-resolve.invalid", "").Validate(context.Background(), "https://does-not-resolve.invalid/form") + if err == nil || !strings.Contains(err.Error(), "cannot verify destination address") { + t.Errorf("unresolvable host: %v", err) + } +} + +func TestOpenReportsChromeThatCannotStart(t *testing.T) { + cfg := DefaultConfig() + cfg.ExecutablePath = "/nonexistent/chrome" + s, err := NewAutomationSession(cfg, testProfile(), URLPolicy{LocalOrigin: "http://127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err := s.Open(context.Background(), "http://127.0.0.1:1/form"); err == nil || !strings.Contains(err.Error(), "start Chrome") { + t.Fatalf("Open with no browser: %v", err) + } +} + +func TestNoteResponseStopsTheSessionOnPrivateAddresses(t *testing.T) { + response := func(ip string) *network.EventResponseReceived { + return &network.EventResponseReceived{Response: &network.Response{RemoteIPAddress: ip}} + } + for ip, blocked := range map[string]bool{"93.184.216.34": false, "": false, "10.0.0.5": true, "127.0.0.1": true, "169.254.169.254": true} { + s := &AutomationSession{policy: NewURLPolicy("https://broker.example", "")} + s.noteResponse(response(ip)) + if got := s.blockedReason() != ""; got != blocked { + t.Errorf("response from %q: blocked = %v, want %v", ip, got, blocked) + } + } + s := &AutomationSession{policy: NewURLPolicy("https://broker.example", "")} + s.noteResponse(&network.EventResponseReceived{}) // no response attached + if s.blockedReason() != "" { + t.Error("an event with no response blocked the session") + } + // The demo's own loopback server is allowed. + s = &AutomationSession{policy: URLPolicy{LocalOrigin: "http://127.0.0.1:9"}} + s.noteResponse(response("127.0.0.1")) + if s.blockedReason() != "" { + t.Error("the demo's local server was blocked") + } +} + +// --- real Chrome -------------------------------------------------------- + +const controlsPage = `