mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
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.
139 lines
4.5 KiB
Go
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
|
|
}
|