diff --git a/cmd/buggregator/main.go b/cmd/buggregator/main.go index 6f9c03f..fb2e980 100644 --- a/cmd/buggregator/main.go +++ b/cmd/buggregator/main.go @@ -144,7 +144,7 @@ func main() { } // Build event service and inject into TCP modules. - eventService := httpserver.NewEventService(store, hub, registry, collector) + eventService := httpserver.NewEventService(store, hub, registry, collector, db) if enabled.IsEnabled("monolog") { monologMod.SetEventService(eventService) } diff --git a/internal/app/app.go b/internal/app/app.go index edbf782..bb62682 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -66,7 +66,7 @@ func (a *App) Run() { } // Build the event service that ties ingestion -> store -> broadcast. - eventService := httpserver.NewEventService(store, a.hub, a.registry, a.metrics) + eventService := httpserver.NewEventService(store, a.hub, a.registry, a.metrics, a.db) // Set up authentication. authSettings := httpserver.AuthSettings{Enabled: a.cfg.Auth.Enabled} diff --git a/internal/server/http/api_test.go b/internal/server/http/api_test.go index 0a2dd5a..4310625 100644 --- a/internal/server/http/api_test.go +++ b/internal/server/http/api_test.go @@ -39,7 +39,7 @@ func setupAPI(t *testing.T) (*http.ServeMux, *storage.SQLiteStore) { store := storage.NewSQLiteStore(db) hub := ws.NewHub() registry := module.NewRegistry() - es := serverhttp.NewEventService(store, hub, registry, nil) + es := serverhttp.NewEventService(store, hub, registry, nil, db) mux := http.NewServeMux() noopMiddleware := func(next http.Handler) http.Handler { return next } diff --git a/internal/server/http/detect.go b/internal/server/http/detect.go index 714ca50..de9d0e3 100644 --- a/internal/server/http/detect.go +++ b/internal/server/http/detect.go @@ -37,8 +37,9 @@ func detectEventType(r *http.Request) *DetectedEvent { } // Method 3: SDK-specific headers that identify the event type. - isSentryStore := strings.HasSuffix(r.URL.Path, "/store") && !strings.Contains(r.URL.Path, "/profiler/") - if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(r.URL.Path, "/envelope") || isSentryStore { + path := strings.TrimRight(r.URL.Path, "/") + isSentryStore := strings.HasSuffix(path, "/store") && !strings.Contains(path, "/profiler/") + if r.Header.Get("X-Sentry-Auth") != "" || strings.HasSuffix(path, "/envelope") || isSentryStore { return &DetectedEvent{Type: "sentry"} } if r.Header.Get("X-Inspector-Key") != "" || r.Header.Get("X-Inspector-Version") != "" { diff --git a/internal/server/http/detect_test.go b/internal/server/http/detect_test.go index 4a558a1..5891857 100644 --- a/internal/server/http/detect_test.go +++ b/internal/server/http/detect_test.go @@ -109,6 +109,14 @@ func TestDetectEventType(t *testing.T) { }, wantType: "sentry", }, + { + // Sentry PHP SDK 4.x posts to /api/{id}/envelope/ with a trailing slash. + name: "envelope path with trailing slash detects sentry", + makeRequest: func() *nethttp.Request { + return httptest.NewRequest("POST", "http://localhost/api/1/envelope/", nil) + }, + wantType: "sentry", + }, { name: "store path suffix detects sentry", makeRequest: func() *nethttp.Request { @@ -116,6 +124,13 @@ func TestDetectEventType(t *testing.T) { }, wantType: "sentry", }, + { + name: "store path with trailing slash detects sentry", + makeRequest: func() *nethttp.Request { + return httptest.NewRequest("POST", "http://localhost/api/1/store/", nil) + }, + wantType: "sentry", + }, { name: "profiler store path does not detect sentry", makeRequest: func() *nethttp.Request { diff --git a/internal/server/http/event_service.go b/internal/server/http/event_service.go index 6ae3ed2..9dd8d88 100644 --- a/internal/server/http/event_service.go +++ b/internal/server/http/event_service.go @@ -2,6 +2,7 @@ package http import ( "context" + "database/sql" "log/slog" "time" @@ -19,10 +20,11 @@ type EventService struct { hub *ws.Hub registry *module.Registry metrics *metrics.Collector + db *sql.DB // optional; used to auto-register new project keys } -func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector) *EventService { - return &EventService{store: store, hub: hub, registry: registry, metrics: m} +func NewEventService(store event.Store, hub *ws.Hub, registry *module.Registry, m *metrics.Collector, db *sql.DB) *EventService { + return &EventService{store: store, hub: hub, registry: registry, metrics: m, db: db} } // HandleIncoming stores an event, broadcasts preview, and notifies modules. @@ -32,6 +34,20 @@ func (s *EventService) HandleIncoming(ctx context.Context, inc *event.Incoming) inc.Project = defaultProject } + // Auto-register the project so the frontend can see and switch to it. + // Sentry events arrive with a project key derived from the DSN path + // (e.g. /api/123/envelope/ → "123"), which won't exist in the projects + // table seeded only with "default". Without this row, the frontend filters + // the event out of the events list and the sidebar dot stays unlit. + if s.db != nil && inc.Project != defaultProject { + if _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO projects (key, name) VALUES (?, ?)`, + inc.Project, inc.Project, + ); err != nil { + slog.Warn("failed to register project", "key", inc.Project, "err", err) + } + } + ev := event.NewEvent(inc) start := time.Now() diff --git a/internal/server/http/event_service_test.go b/internal/server/http/event_service_test.go new file mode 100644 index 0000000..20e448a --- /dev/null +++ b/internal/server/http/event_service_test.go @@ -0,0 +1,106 @@ +package http_test + +import ( + "context" + "testing" + + "github.com/buggregator/go-buggregator/internal/event" + "github.com/buggregator/go-buggregator/internal/module" + serverhttp "github.com/buggregator/go-buggregator/internal/server/http" + "github.com/buggregator/go-buggregator/internal/server/ws" + "github.com/buggregator/go-buggregator/internal/storage" +) + +func TestEventService_HandleIncoming_AutoRegistersProject(t *testing.T) { + db, err := storage.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + // Run core migrations so the projects table exists with the default seed row. + migrator := storage.NewMigrator(db) + if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil { + t.Fatal(err) + } + if err := migrator.Run(); err != nil { + t.Fatal(err) + } + + store := storage.NewSQLiteStore(db) + hub := ws.NewHub() + registry := module.NewRegistry() + es := serverhttp.NewEventService(store, hub, registry, nil, db) + + // Simulate a Sentry event whose project key comes from the DSN path: + // /api/123/envelope/ → project "123". + inc := &event.Incoming{ + UUID: "evt-1", + Type: "sentry", + Payload: []byte(`{"event_id":"evt-1","message":"boom"}`), + Project: "123", + } + if err := es.HandleIncoming(context.Background(), inc); err != nil { + t.Fatalf("HandleIncoming: %v", err) + } + + // Project row must exist so the frontend can list/switch to it. + var name string + row := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "123") + if err := row.Scan(&name); err != nil { + t.Fatalf("project 123 was not registered: %v", err) + } + + // Sending another event under the same key must not error (INSERT OR IGNORE). + inc2 := &event.Incoming{ + UUID: "evt-2", + Type: "sentry", + Payload: []byte(`{"event_id":"evt-2"}`), + Project: "123", + } + if err := es.HandleIncoming(context.Background(), inc2); err != nil { + t.Fatalf("HandleIncoming (second event): %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE key = ?`, "123").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("project 123 row count = %d, want 1", count) + } +} + +func TestEventService_HandleIncoming_DefaultProjectIsNotRewritten(t *testing.T) { + db, err := storage.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + migrator := storage.NewMigrator(db) + if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil { + t.Fatal(err) + } + if err := migrator.Run(); err != nil { + t.Fatal(err) + } + + // The seeded "default" project row has name "Default" — make sure the + // auto-register path doesn't overwrite it for vanilla events. + store := storage.NewSQLiteStore(db) + es := serverhttp.NewEventService(store, ws.NewHub(), module.NewRegistry(), nil, db) + + inc := &event.Incoming{UUID: "evt-1", Type: "var-dump", Payload: []byte(`{}`)} + if err := es.HandleIncoming(context.Background(), inc); err != nil { + t.Fatal(err) + } + + var name string + if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "default").Scan(&name); err != nil { + t.Fatal(err) + } + if name != "Default" { + t.Errorf("default project name = %q, want %q", name, "Default") + } +} diff --git a/internal/server/http/ingestion.go b/internal/server/http/ingestion.go index 766fd32..e5df185 100644 --- a/internal/server/http/ingestion.go +++ b/internal/server/http/ingestion.go @@ -1,6 +1,7 @@ package http import ( + "encoding/json" "io/fs" "log/slog" "net/http" @@ -71,9 +72,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) { // If the event type was explicitly detected, stop the pipeline — don't // fall through to lower-priority handlers like HTTP dump. if detectedType != "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":true}`)) + writeIngestionResponse(w, detectedType, "") return } continue @@ -89,9 +88,7 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":true}`)) + writeIngestionResponse(w, incoming.Type, incoming.UUID) return } } @@ -103,3 +100,17 @@ func (p *IngestionPipeline) ServeHTTP(w http.ResponseWriter, r *http.Request) { } p.frontend.ServeHTTP(w, r) } + +// writeIngestionResponse writes an ingestion acknowledgement. Sentry SDKs +// inspect the body for {"id":"..."} to confirm successful delivery — returning +// {"status":true} works for most SDKs but trips some validators. Other modules +// stick with the legacy shape. +func writeIngestionResponse(w http.ResponseWriter, eventType, eventID string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if eventType == "sentry" { + _ = json.NewEncoder(w).Encode(map[string]string{"id": eventID}) + return + } + _, _ = w.Write([]byte(`{"status":true}`)) +} diff --git a/internal/server/http/ingestion_test.go b/internal/server/http/ingestion_test.go index 143f2b4..f9b96de 100644 --- a/internal/server/http/ingestion_test.go +++ b/internal/server/http/ingestion_test.go @@ -134,7 +134,7 @@ func buildPipeline(t *testing.T) (*serverhttp.IngestionPipeline, *spyStore, []*s }) hub := ws.NewHub() - es := serverhttp.NewEventService(store, hub, registry, nil) + es := serverhttp.NewEventService(store, hub, registry, nil, db) emptyFS := fstest.MapFS{"index.html": {Data: []byte("")}} pipeline := serverhttp.NewIngestionPipeline(handlers, es, emptyFS) @@ -539,6 +539,130 @@ func TestPipeline_HandlerSummary(t *testing.T) { } } +// TestPipeline_SentryResponse_ReturnsEventID asserts that Sentry SDKs receive +// `{"id":""}` on ingest. Other modules keep the legacy +// `{"status":true}` shape. +func TestPipeline_SentryResponse_ReturnsEventID(t *testing.T) { + pipeline, _, _ := buildPipeline(t) + + body := `{"event_id":"resp-uuid","sent_at":"2026-01-01T00:00:00Z"} +{"type":"event"} +{"event_id":"resp-uuid","level":"error","message":"x","exception":{"values":[{"type":"E","value":"v"}]}}` + + r := httptest.NewRequest("POST", "http://localhost/api/default/envelope/", strings.NewReader(body)) + r.URL.User = url.User("sentry") + r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc") + w := httptest.NewRecorder() + + pipeline.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response not JSON: %v body=%s", err, w.Body.String()) + } + if resp["id"] != "resp-uuid" { + t.Errorf("response id = %q, want %q (body=%s)", resp["id"], "resp-uuid", w.Body.String()) + } +} + +// Sentry envelopes that only contain non-canonical items (transaction, logs, +// session, …) still return a 200 — but with a Sentry-shaped body so the SDK +// considers the request delivered. +func TestPipeline_SentryResponse_TransactionOnly_ReturnsSentryShape(t *testing.T) { + pipeline, _, _ := buildPipeline(t) + + body := `{"event_id":"txn-1"} +{"type":"transaction"} +{"event_id":"txn-1","type":"transaction","transaction":"X","start_timestamp":1.0,"timestamp":2.0,"contexts":{"trace":{"trace_id":"aa","span_id":"bb"}},"spans":[]}` + + r := httptest.NewRequest("POST", "http://localhost/api/default/envelope/", strings.NewReader(body)) + r.URL.User = url.User("sentry") + r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc") + w := httptest.NewRecorder() + pipeline.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response not JSON: %v body=%s", err, w.Body.String()) + } + if _, ok := resp["id"]; !ok { + t.Errorf("expected an `id` field in Sentry response, got %v", resp) + } +} + +// Non-Sentry handlers must keep returning the legacy {"status":true} body to +// avoid breaking existing clients. +func TestPipeline_NonSentryResponse_KeepsLegacyShape(t *testing.T) { + pipeline, _, _ := buildPipeline(t) + + r := httptest.NewRequest("POST", "http://localhost/anything", strings.NewReader(`{"x":1}`)) + w := httptest.NewRecorder() + pipeline.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + + body := strings.TrimSpace(w.Body.String()) + if body != `{"status":true}` { + t.Errorf("response = %q, want {\"status\":true}", body) + } +} + +// TestPipeline_AutoCreatesSentryProject ensures the project key embedded in a +// Sentry DSN path (e.g. /api/12345/envelope/ → "12345") becomes a real row in +// the projects table, otherwise the frontend filters the event out of its list. +func TestPipeline_AutoCreatesSentryProject(t *testing.T) { + db, err := storage.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + migrator := storage.NewMigrator(db) + if err := migrator.AddFromFS("core", storage.CoreMigrations, "migrations"); err != nil { + t.Fatal(err) + } + registry := module.NewRegistry() + registry.Register(sentry.New()) + + mux := http.NewServeMux() + store := storage.NewSQLiteStore(db) + if err := registry.Init(db, mux, store); err != nil { + t.Fatal(err) + } + + hub := ws.NewHub() + es := serverhttp.NewEventService(store, hub, registry, nil, db) + pipeline := serverhttp.NewIngestionPipeline(registry.Handlers(), es, fstest.MapFS{"index.html": {Data: []byte("")}}) + + body := `{"event_id":"sx-1","sent_at":"2026-01-01T00:00:00Z"} +{"type":"event"} +{"event_id":"sx-1","level":"error","message":"x","exception":{"values":[{"type":"E","value":"v"}]}}` + + r := httptest.NewRequest("POST", "http://localhost/api/12345/envelope/", strings.NewReader(body)) + r.Header.Set("X-Sentry-Auth", "Sentry sentry_key=abc") + w := httptest.NewRecorder() + pipeline.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var name string + if err := db.QueryRow(`SELECT name FROM projects WHERE key = ?`, "12345").Scan(&name); err != nil { + t.Fatalf("project 12345 was not auto-registered: %v", err) + } +} + // Helper to pretty-print spy state for debugging. func dumpSpies(t *testing.T, spies []*spyHandler) { t.Helper() diff --git a/modules/sentry/envelope.go b/modules/sentry/envelope.go index 51e843a..dcbe8ab 100644 --- a/modules/sentry/envelope.go +++ b/modules/sentry/envelope.go @@ -1,71 +1,96 @@ package sentry import ( + "bytes" "encoding/json" - "strings" ) // parseEnvelopeItems parses a Sentry envelope body into individual items. -// Envelope format: header_json\n[item_header_json\npayload\n]* +// +// Envelope format (https://develop.sentry.dev/sdk/envelopes/): +// +// header_json\n +// (item_header_json\n payload\n)* +// +// Item payloads may legitimately contain literal newlines when the item header +// declares a `length` (bytes); we honor that. When length is absent we fall +// back to newline-delimited payloads. func parseEnvelopeItems(body []byte) (EnvelopeHeader, []EnvelopeItem, error) { var envHeader EnvelopeHeader var items []EnvelopeItem - data := string(body) - lines := splitEnvelopeLines(data) - - if len(lines) == 0 { + // First line: envelope header. + nl := bytes.IndexByte(body, '\n') + if nl < 0 { + // Single line — try to parse as the header but no items follow. + _ = json.Unmarshal(body, &envHeader) return envHeader, nil, nil } - - // First line is the envelope header. - if err := json.Unmarshal([]byte(lines[0]), &envHeader); err != nil { - // Non-fatal — header may be malformed but items could still be valid. + if err := json.Unmarshal(body[:nl], &envHeader); err != nil { envHeader = EnvelopeHeader{} } + pos := nl + 1 + + for pos < len(body) { + // Skip any blank line separators between items. + for pos < len(body) && body[pos] == '\n' { + pos++ + } + if pos >= len(body) { + break + } - // Remaining lines are item_header/payload pairs. - i := 1 - for i < len(lines) { - headerLine := lines[i] - i++ + // Item header is one line. + nl := bytes.IndexByte(body[pos:], '\n') + var headerLine []byte + if nl < 0 { + headerLine = body[pos:] + pos = len(body) + } else { + headerLine = body[pos : pos+nl] + pos = pos + nl + 1 + } var ih ItemHeader - if err := json.Unmarshal([]byte(headerLine), &ih); err != nil { - // Skip malformed item header. + if err := json.Unmarshal(headerLine, &ih); err != nil { continue } - if ih.Type == "" { continue } - // Next line is the payload. - var payload string - if i < len(lines) { - payload = lines[i] - i++ + // Item payload: prefer the explicit length when present, otherwise read + // up to the next newline. + // + // The bound check uses subtraction (len(body)-pos) rather than addition + // (pos+ih.Length) to avoid signed-int overflow on a crafted envelope: + // a huge ih.Length combined with pos could wrap to a small (even + // negative) result, pass an additive bound, then panic when slicing. + var payload []byte + if ih.Length > 0 && ih.Length <= len(body)-pos { + payload = body[pos : pos+ih.Length] + pos += ih.Length + // Skip the trailing newline (envelope spec allows but doesn't require it). + if pos < len(body) && body[pos] == '\n' { + pos++ + } + } else { + nl := bytes.IndexByte(body[pos:], '\n') + if nl < 0 { + payload = body[pos:] + pos = len(body) + } else { + payload = body[pos : pos+nl] + pos = pos + nl + 1 + } } items = append(items, EnvelopeItem{ Type: ih.Type, - Header: json.RawMessage(headerLine), - Payload: json.RawMessage(payload), + Header: append([]byte(nil), headerLine...), + Payload: append(json.RawMessage(nil), payload...), }) } return envHeader, items, nil } - -// splitEnvelopeLines splits envelope data by newlines, preserving empty lines -// that are part of item bodies. Trims trailing empty line. -func splitEnvelopeLines(data string) []string { - lines := strings.Split(data, "\n") - - // Trim trailing empty lines. - for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { - lines = lines[:len(lines)-1] - } - - return lines -} diff --git a/modules/sentry/envelope_test.go b/modules/sentry/envelope_test.go new file mode 100644 index 0000000..357bce9 --- /dev/null +++ b/modules/sentry/envelope_test.go @@ -0,0 +1,180 @@ +package sentry + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "testing" +) + +func TestParseEnvelopeItems_NewlineDelimited(t *testing.T) { + body := []byte(`{"event_id":"abc","sent_at":"2026-01-01T00:00:00Z"} +{"type":"event"} +{"event_id":"abc","level":"error","message":"boom"}`) + + header, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if header.EventID != "abc" { + t.Errorf("header.EventID = %q, want %q", header.EventID, "abc") + } + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + if items[0].Type != "event" { + t.Errorf("item type = %q, want event", items[0].Type) + } + + var p map[string]any + if err := json.Unmarshal(items[0].Payload, &p); err != nil { + t.Fatalf("payload not JSON: %v", err) + } + if p["message"] != "boom" { + t.Errorf("payload.message = %v, want boom", p["message"]) + } +} + +// Sentry envelopes can encode payloads with explicit byte lengths so that +// payloads containing literal newlines aren't split across items. +func TestParseEnvelopeItems_HonorsLengthForMultilinePayload(t *testing.T) { + payload := "{\n \"message\": \"first\\nsecond\"\n}" + body := []byte("{\"event_id\":\"e1\"}\n" + + "{\"type\":\"event\",\"length\":" + strconv.Itoa(len(payload)) + "}\n" + + payload) + + _, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + if string(items[0].Payload) != payload { + t.Errorf("payload = %q, want %q", items[0].Payload, payload) + } + + var p map[string]any + if err := json.Unmarshal(items[0].Payload, &p); err != nil { + t.Fatalf("payload not JSON: %v", err) + } + if p["message"] != "first\nsecond" { + t.Errorf("payload.message = %v", p["message"]) + } +} + +func TestParseEnvelopeItems_MultipleItems(t *testing.T) { + p1 := `{"event_id":"e1","level":"error"}` + p2 := `{"trace_id":"tt","level":"info","body":"log line"}` + + body := []byte("{\"event_id\":\"e1\"}\n" + + "{\"type\":\"event\",\"length\":" + strconv.Itoa(len(p1)) + "}\n" + + p1 + "\n" + + "{\"type\":\"log\",\"length\":" + strconv.Itoa(len(p2)) + "}\n" + + p2) + + _, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("items = %d, want 2", len(items)) + } + if items[0].Type != "event" { + t.Errorf("item[0].Type = %q", items[0].Type) + } + if items[1].Type != "log" { + t.Errorf("item[1].Type = %q", items[1].Type) + } + if string(items[0].Payload) != p1 { + t.Errorf("item[0].Payload = %q", items[0].Payload) + } + if string(items[1].Payload) != p2 { + t.Errorf("item[1].Payload = %q", items[1].Payload) + } +} + +func TestParseEnvelopeItems_BadItemHeaderIsSkipped(t *testing.T) { + // Item header is unparseable — the parser must move past the next newline + // and not crash. Subsequent valid items should still be parsed. + body := []byte(strings.Join([]string{ + `{"event_id":"e1"}`, + `not-json-header`, + `should-be-skipped`, + `{"type":"event"}`, + `{"message":"ok"}`, + }, "\n")) + + _, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("items = %d, want 1, got %v", len(items), items) + } + if items[0].Type != "event" { + t.Errorf("item[0].Type = %q", items[0].Type) + } +} + +func TestParseEnvelopeItems_HeaderOnly(t *testing.T) { + body := []byte(`{"event_id":"only-header"}`) + header, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if header.EventID != "only-header" { + t.Errorf("header.EventID = %q", header.EventID) + } + if len(items) != 0 { + t.Errorf("items = %d, want 0", len(items)) + } +} + +func TestParseEnvelopeItems_LengthBeyondBody_FallsBackToNewline(t *testing.T) { + // length declares more bytes than the body has — the parser must fall back + // to the newline-delimited path rather than crash. + body := []byte(`{"event_id":"e1"} +{"type":"event","length":9999} +{"message":"trimmed"}`) + + _, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + if !strings.Contains(string(items[0].Payload), "trimmed") { + t.Errorf("payload = %q, expected to contain 'trimmed'", items[0].Payload) + } +} + +// A crafted envelope where length = math.MaxInt would overflow an additive +// bound check (pos + ih.Length wraps to negative) and crash the server when +// slicing. The subtractive bound (len(body)-pos) used in the parser is +// overflow-safe — this test asserts we don't panic and fall through to the +// newline-delimited path. +func TestParseEnvelopeItems_LengthOverflow_DoesNotPanic(t *testing.T) { + body := []byte("{\"event_id\":\"e1\"}\n" + + "{\"type\":\"event\",\"length\":" + strconv.Itoa(math.MaxInt) + "}\n" + + `{"message":"safe"}`) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("parser panicked on overflow length: %v", r) + } + }() + + _, items, err := parseEnvelopeItems(body) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("items = %d, want 1", len(items)) + } + if !strings.Contains(string(items[0].Payload), "safe") { + t.Errorf("payload = %q, expected to contain 'safe'", items[0].Payload) + } +} diff --git a/modules/sentry/handler.go b/modules/sentry/handler.go index 92df842..71dc668 100644 --- a/modules/sentry/handler.go +++ b/modules/sentry/handler.go @@ -30,7 +30,7 @@ func (h *handler) Match(r *http.Request) bool { if r.Header.Get("X-Sentry-Auth") != "" { return true } - path := r.URL.Path + path := strings.TrimRight(r.URL.Path, "/") isSentryStore := strings.HasSuffix(path, "/store") && !strings.Contains(path, "/profiler/") return isSentryStore || strings.HasSuffix(path, "/envelope") } diff --git a/modules/sentry/handler_test.go b/modules/sentry/handler_test.go index 3264879..d06cf3a 100644 --- a/modules/sentry/handler_test.go +++ b/modules/sentry/handler_test.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "encoding/json" "net/http/httptest" + "strconv" "strings" "testing" ) @@ -30,6 +31,9 @@ func TestHandler_Match(t *testing.T) { {"POST with sentry auth", "POST", "/", map[string]string{"X-Sentry-Auth": "Sentry sentry_key=abc"}, true}, {"POST to /store", "POST", "/api/123/store", nil, true}, {"POST to /envelope", "POST", "/api/123/envelope", nil, true}, + // Sentry PHP SDK 4.x posts to /api/{id}/envelope/ with a trailing slash. + {"POST to /envelope/ trailing slash", "POST", "/api/123/envelope/", nil, true}, + {"POST to /store/ trailing slash", "POST", "/api/123/store/", nil, true}, {"GET request", "GET", "/api/123/store", nil, false}, {"POST to random path", "POST", "/random", nil, false}, } @@ -69,9 +73,10 @@ func TestHandler_Handle_JSON(t *testing.T) { func TestHandler_Handle_Envelope(t *testing.T) { h := &handler{} - envelope := `{"event_id":"env-uuid"} -{"type":"event","length":25} -{"message":"from envelope"}` + payload := `{"message":"from envelope"}` + envelope := "{\"event_id\":\"env-uuid\"}\n" + + "{\"type\":\"event\",\"length\":" + strconv.Itoa(len(payload)) + "}\n" + + payload r := httptest.NewRequest("POST", "/api/proj/store", strings.NewReader(envelope)) inc, err := h.Handle(r) @@ -89,6 +94,31 @@ func TestHandler_Handle_Envelope(t *testing.T) { } } +func TestHandler_Handle_EnvelopeMultilinePayload(t *testing.T) { + // Payload contains literal newlines — must rely on length, not newline split. + h := &handler{} + payload := "{\n \"message\": \"multi\\nline\"\n}" + envelope := "{\"event_id\":\"env-uuid\"}\n" + + "{\"type\":\"event\",\"length\":" + strconv.Itoa(len(payload)) + "}\n" + + payload + + r := httptest.NewRequest("POST", "/api/proj/store", strings.NewReader(envelope)) + inc, err := h.Handle(r) + if err != nil { + t.Fatal(err) + } + if inc == nil { + t.Fatal("incoming = nil") + } + var p map[string]any + if err := json.Unmarshal(inc.Payload, &p); err != nil { + t.Fatalf("payload not valid JSON: %v", err) + } + if p["message"] != "multi\nline" { + t.Errorf("payload message = %v", p["message"]) + } +} + func TestDecompress(t *testing.T) { t.Run("plain text passthrough", func(t *testing.T) { data := []byte("hello world")