diff --git a/internal/core/browserreceiver/receiver.go b/internal/core/browserreceiver/receiver.go index f2c7e64..36b52e6 100644 --- a/internal/core/browserreceiver/receiver.go +++ b/internal/core/browserreceiver/receiver.go @@ -3,6 +3,7 @@ package browserreceiver import ( "context" + "crypto/subtle" "encoding/json" "fmt" "log" @@ -17,14 +18,21 @@ import ( const capturePath = "/api/browser-inbox/v1/captures" const DefaultAddr = "127.0.0.1:47731" +const receiverTokenHeader = "X-Verstak-Receiver-Token" type Receiver struct { bus *events.Bus workspaceProvider WorkspaceProvider + options Options } type WorkspaceProvider func() string +type Options struct { + RequireToken bool + ReceiverToken string +} + type Server struct { listener net.Listener server *http.Server @@ -63,11 +71,15 @@ type CaptureBrowser struct { } func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver { + return NewWithOptions(bus, Options{}, providers...) +} + +func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProvider) *Receiver { var provider WorkspaceProvider if len(providers) > 0 { provider = providers[0] } - return &Receiver{bus: bus, workspaceProvider: provider} + return &Receiver{bus: bus, workspaceProvider: provider, options: options} } func Start(addr string, receiver *Receiver) (*Server, error) { @@ -119,6 +131,10 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } + if err := r.validateReceiverToken(req); err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } var payload CapturePayload if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { @@ -150,6 +166,24 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { }) } +func (r *Receiver) validateReceiverToken(req *http.Request) error { + if r == nil || !r.options.RequireToken { + return nil + } + expected := strings.TrimSpace(r.options.ReceiverToken) + if expected == "" { + return fmt.Errorf("receiver token required") + } + supplied := strings.TrimSpace(req.Header.Get(receiverTokenHeader)) + if supplied == "" { + return fmt.Errorf("receiver token required") + } + if subtle.ConstantTimeCompare([]byte(supplied), []byte(expected)) != 1 { + return fmt.Errorf("receiver token invalid") + } + return nil +} + func (r *Receiver) annotateWorkspace(payload map[string]interface{}) { if r == nil || r.workspaceProvider == nil || payload == nil { return diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go index 0520ff5..27e658e 100644 --- a/internal/core/browserreceiver/receiver_test.go +++ b/internal/core/browserreceiver/receiver_test.go @@ -127,6 +127,90 @@ func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) { } } +func TestReceiverRequiresTokenWhenPaired(t *testing.T) { + bus := events.NewBus() + received := make(chan events.Event, 1) + bus.Subscribe("browser.capture.page", func(event events.Event) { + received <- event + }) + receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"}) + body := `{ + "schemaVersion": 1, + "captureId": "capture-paired", + "capturedAt": "2026-06-27T00:00:00.000Z", + "source": "verstak-browser-extension", + "kind": "page", + "page": { + "url": "https://example.com/article", + "title": "Example Article" + } + }` + + for _, tc := range []struct { + name string + token string + wantError string + }{ + {name: "missing", token: "", wantError: "receiver token required"}, + {name: "wrong", token: "wrong-token", wantError: "receiver token invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body)) + if tc.token != "" { + req.Header.Set("X-Verstak-Receiver-Token", tc.token) + } + rec := httptest.NewRecorder() + + receiver.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(tc.wantError)) { + t.Fatalf("response body = %q, want %q", rec.Body.String(), tc.wantError) + } + select { + case event := <-received: + t.Fatalf("unexpected event published for rejected capture: %#v", event) + default: + } + }) + } +} + +func TestReceiverAcceptsPairedToken(t *testing.T) { + bus := events.NewBus() + received := make(chan events.Event, 1) + bus.Subscribe("browser.capture.page", func(event events.Event) { + received <- event + }) + receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"}) + body := `{ + "schemaVersion": 1, + "captureId": "capture-paired", + "capturedAt": "2026-06-27T00:00:00.000Z", + "source": "verstak-browser-extension", + "kind": "page", + "page": { + "url": "https://example.com/article", + "title": "Example Article" + } + }` + req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body)) + req.Header.Set("X-Verstak-Receiver-Token", "pair-token") + rec := httptest.NewRecorder() + + receiver.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String()) + } + event := <-received + if event.Name != "browser.capture.page" { + t.Fatalf("event name = %q, want browser.capture.page", event.Name) + } +} + func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) { bus := events.NewBus() bus.Subscribe("browser.capture.page", func(event events.Event) {})