feat: require token for paired browser receiver
This commit is contained in:
parent
7363313f1e
commit
2cbf542b13
|
|
@ -3,6 +3,7 @@ package browserreceiver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -17,14 +18,21 @@ import (
|
||||||
|
|
||||||
const capturePath = "/api/browser-inbox/v1/captures"
|
const capturePath = "/api/browser-inbox/v1/captures"
|
||||||
const DefaultAddr = "127.0.0.1:47731"
|
const DefaultAddr = "127.0.0.1:47731"
|
||||||
|
const receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||||
|
|
||||||
type Receiver struct {
|
type Receiver struct {
|
||||||
bus *events.Bus
|
bus *events.Bus
|
||||||
workspaceProvider WorkspaceProvider
|
workspaceProvider WorkspaceProvider
|
||||||
|
options Options
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceProvider func() string
|
type WorkspaceProvider func() string
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
RequireToken bool
|
||||||
|
ReceiverToken string
|
||||||
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
|
@ -63,11 +71,15 @@ type CaptureBrowser struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
|
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
|
var provider WorkspaceProvider
|
||||||
if len(providers) > 0 {
|
if len(providers) > 0 {
|
||||||
provider = 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) {
|
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"})
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := r.validateReceiverToken(req); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var payload CapturePayload
|
var payload CapturePayload
|
||||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
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{}) {
|
func (r *Receiver) annotateWorkspace(payload map[string]interface{}) {
|
||||||
if r == nil || r.workspaceProvider == nil || payload == nil {
|
if r == nil || r.workspaceProvider == nil || payload == nil {
|
||||||
return
|
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) {
|
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
|
||||||
bus := events.NewBus()
|
bus := events.NewBus()
|
||||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue