fix: bound browser capture ingress
This commit is contained in:
parent
18da09e06b
commit
89c261824f
|
|
@ -4,8 +4,11 @@ package browserreceiver
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -20,6 +23,24 @@ 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"
|
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 {
|
type Receiver struct {
|
||||||
bus *events.Bus
|
bus *events.Bus
|
||||||
workspaceProvider WorkspaceProvider
|
workspaceProvider WorkspaceProvider
|
||||||
|
|
@ -145,8 +166,24 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer req.Body.Close()
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
|
||||||
var payload CapturePayload
|
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")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -215,27 +252,105 @@ func (p CapturePayload) Validate() error {
|
||||||
if strings.TrimSpace(p.CaptureID) == "" {
|
if strings.TrimSpace(p.CaptureID) == "" {
|
||||||
return fmt.Errorf("captureId is required")
|
return fmt.Errorf("captureId is required")
|
||||||
}
|
}
|
||||||
|
if err := validateCaptureText(p.CaptureID, "captureId", maxCaptureIDBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if strings.TrimSpace(p.CapturedAt) == "" {
|
if strings.TrimSpace(p.CapturedAt) == "" {
|
||||||
return fmt.Errorf("capturedAt is required")
|
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" {
|
if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" && p.Kind != "file" {
|
||||||
return fmt.Errorf("unsupported kind")
|
return fmt.Errorf("unsupported kind")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(p.Page.URL) == "" {
|
if strings.TrimSpace(p.Page.URL) == "" {
|
||||||
return fmt.Errorf("page.url is required")
|
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) == "") {
|
if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") {
|
||||||
return fmt.Errorf("selection.text is required")
|
return fmt.Errorf("selection.text is required")
|
||||||
}
|
}
|
||||||
if p.Kind == "link" && (p.Link == nil || strings.TrimSpace(p.Link.URL) == "") {
|
if p.Kind == "selection" {
|
||||||
return fmt.Errorf("link.url is required")
|
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")
|
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")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package browserreceiver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"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())
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue