Files

262 lines
9.9 KiB
Go
Raw Permalink Normal View History

package test
// Process-boundary tests for the CORS door (Task 06): OPTIONS preflight and
// real POST to /speak with headers from the config list
// TALK_ALLOWED_ORIGINS — observed as a black box via raw HTTP requests.
// Binding: exact origins; "*" only explicitly; empty list = no CORS headers
// at all; CORS only on /speak. The door is closed SERVER-SIDE: every request
// with a foreign Origin header outside the permitted path (OPTIONS /speak,
// POST /speak from a listed origin) is rejected with 403 before it has any
// effect — CORS alone only prevents reading the response.
import (
"net/http"
"testing"
"time"
)
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
// Access-Control-Allow-Origin.
func TestCORSPreflightListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO = %q, want %q", got, azOrigin)
}
if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS")
}
if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" {
t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type")
}
if vary := raw.header.Get("Vary"); vary != "Origin" {
t.Errorf("Vary = %q, want Origin (cache correctness)", vary)
}
// Unlisted origin: a clean 204, but deliberately without CORS headers —
// the browser drops the planned POST, the daemon stays reachable.
raw = preflight(t, d, "https://evil.example")
if raw.status != http.StatusNoContent {
t.Fatalf("preflight (unlisted) status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
}
// TestCORSPostListed: the real (non-preflight) POST also carries ACAO when
// the request origin is listed — otherwise not.
func TestCORSPostListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"browser says hello"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO on POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
// Unlisted origin: 403 WITHOUT effect — the fake provider must not see
// a second hit. (Formerly 202 without CORS headers: the browser could
// not read the response, but it was spoken anyway.)
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": "https://evil.example",
"Content-Type": "application/json",
}, `{"text":"evil browser"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
}
// TestCORSEmptyList: without allowed_origins (default) the daemon sends NO
// CORS headers at all — door closed, the browser blocks.
func TestCORSEmptyList(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) // no origins configured
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", raw.status)
}
for _, h := range []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
} {
if got := raw.header.Get(h); got != "" {
t.Errorf("%s = %q with an empty list, want not set", h, got)
}
}
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"without cors"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Without an Origin header (CLI, curl) the same POST remains allowed.
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Content-Type": "application/json",
}, `{"text":"without origin"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
// then the literal "*".
func TestCORSWildcard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": "*",
})
for _, origin := range []string{"https://somewhere.example", "http://localhost:5173"} {
raw := preflight(t, d, origin)
if raw.status != http.StatusNoContent {
t.Fatalf("preflight from %s: status = %d, want 204", origin, raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("ACAO from %s = %q, want *", origin, got)
}
}
}
// TestCORSOnlyOnSpeak: no other endpoint is reachable from the browser — not
// even from a listed origin: OPTIONS and GET on /status with an Origin
// header are 403 without CORS headers.
func TestCORSOnlyOnSpeak(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("OPTIONS /status status = %d, want 403 (no browser access)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("OPTIONS /status ACAO = %q, want empty", got)
}
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("GET /status with Origin: status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
}
}
// TestBrowserCannotControl: the actual attack case. Any web page can send
// "simple requests" without preflight (POST with a text/plain body, form
// POST without body) — the browser only blocks reading the response, not
// sending. talkd must neither speak nor mute nor cancel nor run the MCP tool
// on these: all 403, state unchanged.
func TestBrowserCannotControl(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
})
evil := "https://evil.example"
// Simple request: POST with a text/plain body, no preflight.
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"text":"hijacked","now":true}`)
if raw.status != http.StatusForbidden {
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
}
// Control endpoints need no body — a form POST is enough.
for _, path := range []string{"/mute", "/unmute", "/cancel"} {
raw = doRequest(t, http.MethodPost, d.baseURL+path, map[string]string{"Origin": evil}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
}
}
// Even the listed origin must NOT control — only speak.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
}
// MCP from the browser: closed as well.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
if raw.status != http.StatusForbidden {
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
}
// None of it had any effect: no hit at the provider, not muted.
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); st.Muted {
t.Errorf("daemon is muted — browser POST /mute took effect")
}
}
// TestStatusCorsField: GET /status carries the CORS origins as the cors
// field — listed as [] when the door is closed (compat requirement Task 06).
func TestStatusCorsField(t *testing.T) {
t.Run("listed", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag",
})
st := getStatus(t, d)
if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" {
t.Errorf("cors = %v, want both listed origins", st.Cors)
}
})
t.Run("empty = [] (not null)", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Cors == nil || len(st.Cors) != 0 {
t.Errorf("cors = %v, want [] with the door closed", st.Cors)
}
})
}