fix: bound browser capture ingress

This commit is contained in:
mirivlad 2026-07-10 03:29:51 +08:00
parent 18da09e06b
commit 89c261824f
2 changed files with 216 additions and 5 deletions

View File

@ -4,8 +4,11 @@ package browserreceiver
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
@ -20,6 +23,24 @@ const capturePath = "/api/browser-inbox/v1/captures"
const DefaultAddr = "127.0.0.1:47731"
const receiverTokenHeader = "X-Verstak-Receiver-Token"
const (
maxCaptureBodyBytes = 12 * 1024 * 1024
maxCaptureIDBytes = 256
maxCapturedAtBytes = 64
maxCaptureSourceBytes = 128
maxPageURLBytes = 4096
maxPageTitleBytes = 512
maxPageDomainBytes = 255
maxSelectionTextBytes = 20 * 1024
maxLinkTextBytes = 512
maxBrowserNameBytes = 64
maxFileNameBytes = 255
maxFileMimeBytes = 128
maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
)
type Receiver struct {
bus *events.Bus
workspaceProvider WorkspaceProvider
@ -145,8 +166,24 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
var payload CapturePayload
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
if err := decoder.Decode(&payload); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@ -215,27 +252,105 @@ func (p CapturePayload) Validate() error {
if strings.TrimSpace(p.CaptureID) == "" {
return fmt.Errorf("captureId is required")
}
if err := validateCaptureText(p.CaptureID, "captureId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CapturedAt) == "" {
return fmt.Errorf("capturedAt is required")
}
if err := validateCaptureText(p.CapturedAt, "capturedAt", maxCapturedAtBytes); err != nil {
return err
}
if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" && p.Kind != "file" {
return fmt.Errorf("unsupported kind")
}
if strings.TrimSpace(p.Page.URL) == "" {
return fmt.Errorf("page.url is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.URL, "page.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Title, "page.title", maxPageTitleBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Domain, "page.domain", maxPageDomainBytes); err != nil {
return err
}
if p.Browser != nil {
if err := validateCaptureText(p.Browser.Name, "browser.name", maxBrowserNameBytes); err != nil {
return err
}
}
if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") {
return fmt.Errorf("selection.text is required")
}
if p.Kind == "link" && (p.Link == nil || strings.TrimSpace(p.Link.URL) == "") {
return fmt.Errorf("link.url is required")
if p.Kind == "selection" {
if err := validateCaptureText(p.Selection.Text, "selection.text", maxSelectionTextBytes); err != nil {
return err
}
}
if p.Kind == "file" && (p.File == nil || strings.TrimSpace(p.File.Name) == "") {
if p.Kind == "link" {
if p.Link == nil || strings.TrimSpace(p.Link.URL) == "" {
return fmt.Errorf("link.url is required")
}
if err := validateCaptureText(p.Link.URL, "link.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Link.Text, "link.text", maxLinkTextBytes); err != nil {
return err
}
}
if p.Kind == "file" {
if err := p.validateFile(); err != nil {
return err
}
}
return nil
}
func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required")
}
if p.Kind == "file" && (p.File == nil || (p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "")) {
if p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "" {
return fmt.Errorf("file.text or file.dataBase64 is required")
}
if p.File.Size < 0 || p.File.Size > maxFileBytes {
return fmt.Errorf("file.size exceeds limit")
}
if err := validateCaptureText(p.File.Name, "file.name", maxFileNameBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Mime, "file.mime", maxFileMimeBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Text, "file.text", maxFileTextBytes); err != nil {
return err
}
dataBase64 := strings.TrimSpace(p.File.DataBase64)
if dataBase64 == "" {
return nil
}
if len(dataBase64) > maxFileDataBase64Bytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
data, err := base64.StdEncoding.DecodeString(dataBase64)
if err != nil {
return fmt.Errorf("file.dataBase64 is invalid")
}
if len(data) > maxFileBytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
return nil
}
func validateCaptureText(value, field string, maxBytes int) error {
if len(value) > maxBytes {
return fmt.Errorf("%s exceeds limit", field)
}
return nil
}

View File

@ -2,10 +2,13 @@ package browserreceiver
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/events"
@ -363,3 +366,96 @@ func TestReceiverRejectsInvalidCapturePayload(t *testing.T) {
t.Fatalf("response body = %q, want validation error", rec.Body.String())
}
}
func TestReceiverRejectsOversizedCaptureBody(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus)
const maxCaptureBodyBytes = 12 * 1024 * 1024
body := fmt.Sprintf(`{
"schemaVersion": 1,
"captureId": "capture-oversized",
"capturedAt": "2026-06-27T00:00:00.000Z",
"kind": "page",
"page": {"url": "https://example.com", "title": %q}
}`, strings.Repeat("x", maxCaptureBodyBytes))
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", strings.NewReader(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
select {
case event := <-received:
t.Fatalf("unexpected event published for oversized capture: %#v", event)
default:
}
}
func TestCapturePayloadRejectsUnsafeFileContent(t *testing.T) {
const maxFileBytes = 8 * 1024 * 1024
const maxFileTextBytes = 2 * 1024 * 1024
newFilePayload := func() CapturePayload {
return CapturePayload{
SchemaVersion: 1,
CaptureID: "capture-file-validation",
CapturedAt: "2026-06-27T00:00:00.000Z",
Kind: "file",
Page: CapturePage{URL: "https://example.com"},
File: &CaptureFile{Name: "attachment.bin", Size: 1, DataBase64: "AQ=="},
}
}
tests := []struct {
name string
edit func(*CapturePayload)
want string
}{
{
name: "oversized declared size",
edit: func(payload *CapturePayload) {
payload.File.Size = maxFileBytes + 1
},
want: "file.size exceeds limit",
},
{
name: "invalid base64",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = "not base64"
},
want: "file.dataBase64 is invalid",
},
{
name: "oversized decoded data",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = base64.StdEncoding.EncodeToString(make([]byte, maxFileBytes+1))
},
want: "file.dataBase64 exceeds limit",
},
{
name: "oversized text",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = ""
payload.File.Text = strings.Repeat("x", maxFileTextBytes+1)
},
want: "file.text exceeds limit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := newFilePayload()
tt.edit(&payload)
if err := payload.Validate(); err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
}
})
}
}