From 6b3ac4ab86bfb412984ef3cd7f53aba7621a5703 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:39:45 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20HTTP=20Response=20Splitting=20via=20environment=20variables?= =?UTF-8?q?=20in=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tejjasdev <213147578+tejjasdev@users.noreply.github.com> --- .jules/sentinel.md | 6 ++++++ internal/responder/responder.go | 19 ++++++++++++++++++- internal/responder/responder_test.go | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .jules/sentinel.md 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) + } +}