diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..77b79e4 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,6 @@ +## Sentinel's Journal + +## 2026-10-08 - Added CORS headers protection +**Vulnerability:** CRLF Injection in headers via Environment Variables (HTTP Response Splitting). +**Learning:** `responder.Render()` expands `${VAR}` in header values using `r.env.Expand(h.Value)`. However, the environment variable values themselves are not sanitized! `Spec.Validate()` only checks the literal header values in the config/CLI before expansion (`validHeaderValue`). If an environment variable like `PORTBELL_API_KEY` contains `\r\n` (CRLF), the expanded value will be injected into the raw HTTP response, leading to HTTP Response Splitting. +**Prevention:** Always validate expanded values, or strip `\r` and `\n` from environment variables during expansion in headers. diff --git a/internal/responder/responder.go b/internal/responder/responder.go index 6eaa674..8afdc42 100644 --- a/internal/responder/responder.go +++ b/internal/responder/responder.go @@ -167,12 +167,29 @@ func (r *Responder) Set(s Spec) error { return nil } +func sanitizeHeaderValue(v string) string { + if validHeaderValue(v) { + return v + } + var b strings.Builder + b.Grow(len(v)) + for i := 0; i < len(v); i++ { + c := v[i] + if c == '\t' || (c >= 0x20 && c != 0x7f) { + b.WriteByte(c) + } else { + b.WriteByte(' ') + } + } + return b.String() +} + // Render returns the reply to send now, with placeholders expanded. func (r *Responder) Render() events.Response { s := r.Get() headers := make([]events.Header, len(s.Headers)) for i, h := range s.Headers { - headers[i] = events.Header{Name: h.Name, Value: r.env.Expand(h.Value)} + headers[i] = events.Header{Name: h.Name, Value: sanitizeHeaderValue(r.env.Expand(h.Value))} } return events.Response{ Status: s.Status, diff --git a/internal/responder/responder_test.go b/internal/responder/responder_test.go index d2d462a..64d5579 100644 --- a/internal/responder/responder_test.go +++ b/internal/responder/responder_test.go @@ -124,3 +124,21 @@ func TestFormatParseRoundTrip(t *testing.T) { t.Fatalf("round trip mismatch: %+v", back) } } + +func TestResponderRenderSanitizesHeaders(t *testing.T) { + env := map[string]string{"PORTBELL_INJECT": "val\r\nInjected: 1"} + p := EnvPolicy{ + Prefix: "PORTBELL_", + Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }, + } + r := New(Spec{ + Status: 200, + Headers: []events.Header{{Name: "X-Test", Value: "${PORTBELL_INJECT}"}}, + Body: "body", + }, p) + + out := r.Render() + if out.Headers[0].Value != "val Injected: 1" { + t.Fatalf("expected sanitized header, got %q", out.Headers[0].Value) + } +}