feat: require token for paired browser receiver
This commit is contained in:
parent
7363313f1e
commit
2cbf542b13
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {})
|
||||
|
|
|
|||
Loading…
Reference in New Issue