diff --git a/internal/core/browserreceiver/receiver.go b/internal/core/browserreceiver/receiver.go index 9d744c0..5d25874 100644 --- a/internal/core/browserreceiver/receiver.go +++ b/internal/core/browserreceiver/receiver.go @@ -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 } diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go index 4beb920..1e90701 100644 --- a/internal/core/browserreceiver/receiver_test.go +++ b/internal/core/browserreceiver/receiver_test.go @@ -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) + } + }) + } +}