Files
zeekay bc2c3be4a4 fix(errortracking): harden ingest per red review (HIGH-1/2, MED-1/2/3, LOW-1/2)
HIGH-1 amplification: cap events/envelope (1000); module.Ingest pre-aggregates
occurrences by fingerprint and store.UpsertIssues writes the whole batch in ONE
tx (a flood of identical events => one upsert, count+=N); per-org issue ceiling
(10000) drops NEW fingerprints past the cap; per-org token-bucket rate limit
(50/s burst 100) after DSN verify; retention/TTL sweep (DeleteStale) gated by
O11Y_ERRORTRACKING_RETENTION_DAYS (default 90).

MEDIUM-1: envelope item length is bounds-checked (>=0 && <= remaining) before it
is added to pos — a MaxInt64/negative length no longer overflows the slice bound;
falls back to newline framing.

MEDIUM-2: default-on secret redaction (sk-/AKIA/bearer/JWT/conn-string/PAN, always)
+ PII scrub (email/IP) unless O11Y_ERRORTRACKING_CAPTURE_PII=true (fail-secure,
mirrors llmobs capture-messages). Applied before the value enters the fingerprint.

MEDIUM-3: DSN keys are versioned '<v>:<hmac>' with a per-org revocation watermark
(o11y_ingest_revocations, wholesale-cached); rotate ONE org's min-version to revoke
only its below-min DSNs — no global secret roll.

LOW-1: lifecycle UpdateIssue is optimistic-concurrency guarded (version column,
WHERE version=expected, bump on write; stale => 409 conflict, not clobber). Ingest
never touches version.
LOW-2: orgFromContext uses NewUUID+error (no MustNewUUID panic on a bad claim).
INFO-1: corrected the occurrence-persistence claim + removed the dangling chsink.go
reference (only NoopSink exists; logs sink is an honest fast-follow).

Tests: 67 pass incl amplification-bounded (5000 events -> 1 upsert), event cap,
per-org ceiling, MaxInt64/negative length no-panic, rate-limit, secret/PII scrub,
versioned-key + isolated revocation, optimistic-update conflict, DeleteStale.
Crown-jewel isolation/parity/DSN tests unchanged and still green.
2026-07-10 14:54:25 -07:00

139 lines
4.5 KiB
Go

package implerrortracking
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"encoding/json"
"io"
"strings"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
)
// maxDecodedBytes caps the decompressed payload so a small gzip bomb cannot
// exhaust memory on this public endpoint. 24 MiB comfortably fits large stack
// traces / batched envelopes while staying bounded.
const maxDecodedBytes = 24 << 20
// maxEventsPerEnvelope caps the events extracted from ONE request, so a single
// envelope cannot fan out into an unbounded number of issue upserts (amplification
// backpressure). Real SDKs send one event per envelope; batching senders stay well
// under this.
const maxEventsPerEnvelope = 1000
// decodeBody transparently inflates gzip / zlib / raw-deflate request bodies (the
// Content-Encodings Sentry SDKs use), bounded by maxDecodedBytes. Identity/unknown
// encodings pass through unchanged.
func decodeBody(body []byte, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "identity":
return body, nil
case "gzip", "x-gzip":
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer func() { _ = r.Close() }()
return io.ReadAll(io.LimitReader(r, maxDecodedBytes))
case "deflate":
if r, err := zlib.NewReader(bytes.NewReader(body)); err == nil {
defer func() { _ = r.Close() }()
return io.ReadAll(io.LimitReader(r, maxDecodedBytes))
}
// Some clients send headerless raw DEFLATE.
fr := flate.NewReader(bytes.NewReader(body))
defer func() { _ = fr.Close() }()
return io.ReadAll(io.LimitReader(fr, maxDecodedBytes))
default:
return body, nil
}
}
// parseStoreBody decodes a legacy `/store/` payload: a single event JSON.
func parseStoreBody(body []byte) ([]*errortrackingtypes.SentryEvent, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "empty store payload")
}
var ev errortrackingtypes.SentryEvent
if err := json.Unmarshal(body, &ev); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "invalid store event json")
}
return []*errortrackingtypes.SentryEvent{&ev}, nil
}
// parseEnvelope decodes a Sentry envelope and returns every `event`-type item.
// The envelope is newline-framed: a header line, then repeating (item-header,
// payload) pairs where a payload is either length-delimited (per its header) or
// runs to the next newline. Non-event items (transaction/session/attachment/…)
// are skipped. Malformed tails are tolerated — we return what parsed cleanly.
func parseEnvelope(body []byte) ([]*errortrackingtypes.SentryEvent, error) {
pos := 0
readLine := func() ([]byte, bool) {
if pos >= len(body) {
return nil, false
}
if nl := bytes.IndexByte(body[pos:], '\n'); nl >= 0 {
line := body[pos : pos+nl]
pos += nl + 1
return line, true
}
line := body[pos:]
pos = len(body)
return line, true
}
// Envelope header (event_id / dsn / sent_at) — required to be present but not
// otherwise consumed here.
if _, ok := readLine(); !ok {
return nil, errors.Newf(errors.TypeInvalidInput, errortrackingtypes.ErrCodeErrorTrackingInvalidInput, "empty envelope")
}
var events []*errortrackingtypes.SentryEvent
for pos < len(body) {
if len(events) >= maxEventsPerEnvelope {
break // amplification cap: ignore the tail of an oversized envelope
}
hdrLine, ok := readLine()
if !ok {
break
}
if len(bytes.TrimSpace(hdrLine)) == 0 {
continue
}
var ih errortrackingtypes.EnvelopeItemHeader
if err := json.Unmarshal(hdrLine, &ih); err != nil {
break // corrupt framing; stop rather than misread payloads as headers
}
var payload []byte
// Use the declared length ONLY when it is in-bounds — never add an
// attacker-supplied int to pos (a huge/negative length would overflow past
// the clamp and panic the slice). Out-of-range → treat as newline-delimited.
if ih.Length != nil && *ih.Length >= 0 && *ih.Length <= len(body)-pos {
end := pos + *ih.Length
payload = body[pos:end]
pos = end
if pos < len(body) && body[pos] == '\n' {
pos++
}
} else {
payload, ok = readLine()
if !ok {
break
}
}
if ih.Type == "event" {
var ev errortrackingtypes.SentryEvent
if err := json.Unmarshal(payload, &ev); err == nil {
events = append(events, &ev)
}
}
}
return events, nil
}