Files
traefik-llmgateway/sse.go
T
lukaszraczylo b3b8ae6ad5 fix(security): harden retry, SSE, Redis and cost-budget paths
The gateway refuses a request for an unpriced model when the caller has a
cost budget. An unpriced model cost 0, so the budget counter was never
written and the budget could never fire. The new
allowUnpricedWithCostBudget setting restores the old behaviour.

The retry loop honours an upstream Retry-After of up to 30s and jitters
its own computed backoff. SSE parsing bounds the total size of one event.
The Redis client can use TLS. Provider base URLs and errors are scrubbed
of credentials before they reach logs or the admin view. The gateway
warns when failover and retry settings multiply into a large number of
outbound calls.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 09:35:38 +01:00

291 lines
12 KiB
Go

package traefikllmgateway
import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"strings"
)
// sseScannerInitialBufSize is bufio.Scanner's starting token buffer for
// readSSE. It grows on demand up to sseScannerMaxLineSize.
const sseScannerInitialBufSize = 64 * 1024
// sseScannerMaxLineSize caps a single text/event-stream field line at
// 4MiB. Upstream LLM SSE events can carry large data lines — a
// base64-encoded image, for instance — so the cap is generous, but a line
// beyond it makes bufio.Scanner return bufio.ErrTooLong instead of
// growing its buffer without bound.
const sseScannerMaxLineSize = 4 * 1024 * 1024
// sseMaxEventBytes caps the TOTAL data bytes readSSE will accumulate for
// ONE event, across however many "data:" lines that event spans.
//
// Security audit run-1: sseScannerMaxLineSize above bounds a single LINE,
// not an event, and readSSE's accumulator resets only on a blank line or
// at EOF — so an upstream that emits many short "data:" lines and never
// terminates the record grows dataLines without any bound at all, and
// peak residency at dispatch is roughly three times that (the joined
// string, its []byte copy, and the slice, all live at once). The per-line
// cap never fires for that shape, because no individual line is large.
// Under deployment form A the heap being grown is the shared Traefik
// ingress process's, so this is not self-inflicted: it is every other
// tenant and every unrelated route on that instance.
//
// The value is generous — an event legitimately carrying a base64 image
// stays far below it — and is a backstop against unbounded growth, not a
// tuning knob.
const sseMaxEventBytes = 8 * 1024 * 1024
// errSSEEventTooLarge is returned by readSSE when one event's accumulated
// "data:" payload exceeds sseMaxEventBytes. It mirrors how the per-line
// cap surfaces (bufio.ErrTooLong is returned as-is): the caller's stream
// forwarding stops and the error propagates, rather than the gateway
// continuing to buffer an event that will never terminate.
var errSSEEventTooLarge = errors.New("llmgateway: sse event data exceeded the maximum accumulated size")
// sseDataPrefix is the "data:" field prefix sseWriter writes before every
// event payload, per the text/event-stream wire format.
const sseDataPrefix = "data: "
// sseBOM is the UTF-8 byte-order mark. Some proxies still add one at the
// start of a stream. Left in place, it attaches to the first field name.
// "data:" becomes "\ufeffdata:", and readSSE drops the first event as an
// unknown field. readSSE removes one leading BOM from the first line.
const sseBOM = "\ufeff"
// sseEvent is one parsed text/event-stream event: its "event:" field
// (empty when the stream omits it) and its "data:" field, with multi-line
// data joined by "\n" per the SSE spec.
type sseEvent struct {
event string
data []byte
}
// readSSE parses r as a text/event-stream body and calls fn once per
// event. It accumulates "event:" and "data:" field lines. Multiple
// "data:" lines join with "\n". It dispatches fn on a blank line, then
// resets the accumulated fields. Comment lines that start with ":", and
// "id:"/"retry:" fields, are recognized and silently ignored. Line
// endings can be "\n" or "\r\n": bufio.ScanLines, the scanner's split
// function, strips an optional trailing "\r" itself. A leading UTF-8
// byte-order mark on the stream's first line is stripped before field
// parsing.
//
// readSSE follows the EventSource specification: it fires fn only when
// the data buffer is not empty. An "event:" field alone, or a lone
// "data:\n\n" line, produces no event.
//
// readSSE stops and returns fn's error the first time fn returns one. On
// a clean EOF it returns nil, and first dispatches one final event if
// fields were pending with no trailing blank line. This EOF flush is a
// deliberate deviation from the specification, which discards an
// unterminated event instead. Callers must treat that final event as
// possibly incomplete, since the upstream connection can drop mid-frame.
// A field line longer than sseScannerMaxLineSize makes the underlying
// scanner fail. That error, bufio.ErrTooLong, is returned as-is.
func readSSE(r io.Reader, fn func(sseEvent) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, sseScannerInitialBufSize), sseScannerMaxLineSize)
var (
event string
dataLines []string
hasData bool
dataBytes int
)
firstLine := true
// dispatch fires fn on a blank line, but only when the data buffer is
// not empty. The specification discards an event whose data buffer
// is empty even when a "data:" line was seen, so a lone "data:\n\n"
// line must not reach fn. dispatch always resets the accumulated
// fields, dispatched or not.
dispatch := func() error {
joined := strings.Join(dataLines, "\n")
fire := hasData && joined != ""
ev := sseEvent{event: event, data: []byte(joined)}
// Three separate assignments, not one "event, dataLines, hasData =
// \"\", nil, false" multi-value statement: yaegi v0.16.1 panics
// ("reflect: New(nil)") interpreting a 3-way assignment mixing a
// string, a nil slice, and a bool literal in one statement — the
// same class of bug as resolveAgainst's local-var fix (registry.go),
// just in assignment form instead of a return tuple. The identical
// values assigned one statement at a time do not trigger it.
// Verified empirically under real Traefik (Task 15's integration
// suite, the SSE streaming path); tools/yaegi-check never exercises
// a live streaming response.
event = ""
dataLines = nil
hasData = false
dataBytes = 0
if !fire {
return nil
}
return fn(ev)
}
for scanner.Scan() {
line := scanner.Text()
if firstLine {
firstLine = false
line = strings.TrimPrefix(line, sseBOM)
}
if line == "" {
if err := dispatch(); err != nil {
return err
}
continue
}
if strings.HasPrefix(line, ":") {
continue // comment line, per the SSE spec
}
field, value, _ := strings.Cut(line, ":")
value = strings.TrimPrefix(value, " ")
switch field {
case "event":
event = value
case "data":
// +1 for the "\n" this line will contribute to the joined
// payload. Checked BEFORE appending, so the cap bounds what is
// actually held rather than catching it one line late.
dataBytes += len(value) + 1
if dataBytes > sseMaxEventBytes {
return errSSEEventTooLarge
}
dataLines = append(dataLines, value)
hasData = true
case "id", "retry":
// recognized fields, intentionally ignored
}
}
if err := scanner.Err(); err != nil {
return err
}
return dispatch()
}
// sseWriter writes Server-Sent Events over an http.ResponseWriter,
// flushing after each event so a proxied client sees each chunk as it is
// produced instead of buffered until the response closes.
type sseWriter struct {
w http.ResponseWriter
f http.Flusher
}
// newSSEWriter returns an sseWriter over w, setting the SSE response
// headers before anything is written: Content-Type: text/event-stream,
// Cache-Control: no-cache, and X-Accel-Buffering: no (disables buffering
// in a reverse proxy such as nginx sitting in front of the gateway). w
// need not implement http.Flusher — the Gateway's statusTrackingWriter
// always does, since it delegates — but writeData/writeDone tolerate its
// absence by skipping the flush.
//
// KNOWN LIMITATION under real Traefik (yaegi v0.16.1), confirmed by
// Task 15's integration suite: the w.(http.Flusher) assertion below
// always reports false, because Yaegi wraps any http.ResponseWriter
// argument crossing from Traefik's compiled dispatcher into this
// interpreted package in a synthetic type scoped to exactly the
// http.ResponseWriter method set — Header/Write/WriteHeader — dropping
// any other interface the real writer satisfies. The same holds one
// layer down: statusTrackingWriter.Flush's own w.rw.(http.Flusher)
// check, and even http.NewResponseController(w.rw).Flush(), fail the
// same way, all verified empirically. Net effect: streamed responses
// (chat completions, MCP/A2A proxying via flushWriter below) are
// correct in content but arrive as one batch when the handler returns,
// not incrementally, when this plugin is loaded via localPlugins —
// unlike compiled Go, where this exact code streams correctly (see the
// unit tests). Confirmed as an external, still-open Yaegi/Traefik bug,
// not a defect in this code: traefik/yaegi#1600 and, with the
// "confirmed bug" label from Traefik's own maintainers,
// traefik/traefik#10269. No code-level workaround is known.
func newSSEWriter(w http.ResponseWriter) *sseWriter {
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
f, _ := w.(http.Flusher)
return &sseWriter{w: w, f: f}
}
// writeData writes one SSE data event as a single Write call, flushing
// when the underlying writer supports that.
//
// A newline inside b is now ENCODED rather than emitted raw: b is split
// on "\n" and each line gets its own "data: " prefix, which is exactly
// how the text/event-stream format represents a multi-line payload, and
// is what a conforming client rejoins with "\n" on the other side. For
// single-line b — every caller that passes compact JSON, which is still
// what callers should pass — the bytes written are byte-for-byte
// identical to before.
//
// Security audit run-1: this function previously DOCUMENTED that b must
// not contain a newline and enforced nothing, while readSSE on the other
// side is specified (and unit-tested) to JOIN multi-line data with "\n".
// openaiAdapter.forwardStream relays readSSE's output straight here, so
// an upstream could close the frame early and inject a complete second
// event — carrying its own event:/id:/retry: fields, which this gateway
// otherwise strips — into the client's stream. Encoding the newline
// closes that by construction, with no validation branch to get wrong.
func (s *sseWriter) writeData(b []byte) error {
lines := bytes.Split(b, []byte{'\n'})
buf := make([]byte, 0, len(b)+len(lines)*len(sseDataPrefix)+1)
for _, line := range lines {
buf = append(buf, sseDataPrefix...)
buf = append(buf, line...)
buf = append(buf, '\n')
}
buf = append(buf, '\n')
if _, err := s.w.Write(buf); err != nil {
return err
}
if s.f != nil {
s.f.Flush()
}
return nil
}
// writeDone writes the SSE stream's terminal "data: [DONE]\n\n" sentinel
// (the OpenAI streaming convention) and flushes. Write errors are not
// returned — writeDone is the last thing a handler does before returning,
// and the connection is about to close either way.
func (s *sseWriter) writeDone() {
_, _ = s.w.Write([]byte(sseDataPrefix + "[DONE]\n\n"))
if s.f != nil {
s.f.Flush()
}
}
// flushWriter wraps an io.Writer, flushing after every Write. Passthrough
// and MCP proxying use it as the destination for io.Copy, so a streamed
// reverse-proxy body reaches the client incrementally instead of sitting
// in a buffer until the copy finishes.
type flushWriter struct {
w io.Writer
f http.Flusher
}
// newFlushWriter returns a flushWriter wrapping w, asserting once
// whether w implements http.Flusher. Callers that stream a
// reverse-proxy copy build one flushWriter per response, instead of
// repeating the same type assertion at every call site.
func newFlushWriter(w io.Writer) *flushWriter {
f, _ := w.(http.Flusher)
return &flushWriter{w: w, f: f}
}
// Write delegates to the wrapped io.Writer, then flushes when f is
// non-nil.
func (fw *flushWriter) Write(b []byte) (int, error) {
n, err := fw.w.Write(b)
if fw.f != nil {
fw.f.Flush()
}
return n, err
}