feat: require token for paired browser receiver

This commit is contained in:
mirivlad 2026-06-29 04:52:19 +08:00
parent 7363313f1e
commit 2cbf542b13
2 changed files with 119 additions and 1 deletions

View File

@ -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

View File

@ -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) {})