2026-10-18 20:06:11 +02:00
|
|
|
package test
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:11 +02:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"testing"
|
2026-10-24 09:35:45 +02:00
|
|
|
"time"
|
2026-10-18 20:06:11 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const azOrigin = "https://chat.az-gruppe.com"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
|
|
|
|
|
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
|
2026-10-18 20:06:11 +02:00
|
|
|
// Access-Control-Allow-Origin.
|
2026-10-24 11:15:35 +02:00
|
|
|
func TestCORSPreflightListed(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("preflight status = %d, want 204", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Vary = %q, want Origin (cache correctness)", vary)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Unlisted origin: a clean 204, but deliberately without CORS headers —
|
|
|
|
|
// the browser drops the planned POST, the daemon stays reachable.
|
2026-10-18 20:06:11 +02:00
|
|
|
raw = preflight(t, d, "https://evil.example")
|
|
|
|
|
if raw.status != http.StatusNoContent {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("preflight (unlisted) status = %d, want 204", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCORSPostListed: the real (non-preflight) POST also carries ACAO when
|
|
|
|
|
// the request origin is listed — otherwise not.
|
|
|
|
|
func TestCORSPostListed(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"text":"browser says hello"}`)
|
2026-10-18 20:06:11 +02:00
|
|
|
if raw.status != http.StatusAccepted {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("POST /speak status = %d, want 202", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ACAO on POST = %q, want %q", got, azOrigin)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
fake.waitCompleted(t, 1)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.)
|
2026-10-18 20:06:11 +02:00
|
|
|
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|
|
|
|
"Origin": "https://evil.example",
|
|
|
|
|
"Content-Type": "application/json",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"text":"evil browser"}`)
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
2026-10-24 09:35:45 +02:00
|
|
|
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCORSEmptyList: without allowed_origins (default) the daemon sends NO
|
|
|
|
|
// CORS headers at all — door closed, the browser blocks.
|
|
|
|
|
func TestCORSEmptyList(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
2026-10-24 11:15:35 +02:00
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil) // no origins configured
|
2026-10-18 20:06:11 +02:00
|
|
|
|
|
|
|
|
raw := preflight(t, d, azOrigin)
|
|
|
|
|
if raw.status != http.StatusNoContent {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("preflight status = %d, want 204", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
for _, h := range []string{
|
|
|
|
|
"Access-Control-Allow-Origin",
|
|
|
|
|
"Access-Control-Allow-Methods",
|
|
|
|
|
"Access-Control-Allow-Headers",
|
|
|
|
|
} {
|
|
|
|
|
if got := raw.header.Get(h); got != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("%s = %q with an empty list, want not set", h, got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|
|
|
|
"Origin": azOrigin,
|
|
|
|
|
"Content-Type": "application/json",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"text":"without cors"}`)
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Without an Origin header (CLI, curl) the same POST remains allowed.
|
2026-10-24 09:35:45 +02:00
|
|
|
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|
|
|
|
"Content-Type": "application/json",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"text":"without origin"}`)
|
2026-10-18 20:06:11 +02:00
|
|
|
if raw.status != http.StatusAccepted {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
2026-10-24 09:35:45 +02:00
|
|
|
fake.waitCompleted(t, 1)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
|
|
|
|
|
// then the literal "*".
|
2026-10-18 20:06:11 +02:00
|
|
|
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": "*",
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
for _, origin := range []string{"https://somewhere.example", "http://localhost:5173"} {
|
2026-10-18 20:06:11 +02:00
|
|
|
raw := preflight(t, d, origin)
|
|
|
|
|
if raw.status != http.StatusNoContent {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("preflight from %s: status = %d, want 204", origin, raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("ACAO from %s = %q, want *", origin, got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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,
|
|
|
|
|
}, "")
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("OPTIONS /status status = %d, want 403 (no browser access)", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("OPTIONS /status ACAO = %q, want empty", got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
|
|
|
|
|
"Origin": azOrigin,
|
|
|
|
|
}, "")
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("GET /status with Origin: status = %d, want 403", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-24 09:35:45 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
2026-10-24 11:15:35 +02:00
|
|
|
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
|
2026-10-24 09:35:45 +02:00
|
|
|
})
|
|
|
|
|
evil := "https://evil.example"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Simple request: POST with a text/plain body, no preflight.
|
2026-10-24 09:35:45 +02:00
|
|
|
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
|
|
|
|
|
"Origin": evil,
|
|
|
|
|
"Content-Type": "text/plain",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"text":"hijacked","now":true}`)
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Control endpoints need no body — a form POST is enough.
|
2026-10-24 09:35:45 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Even the listed origin must NOT control — only speak.
|
2026-10-24 09:35:45 +02:00
|
|
|
raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "")
|
|
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// MCP from the browser: closed as well.
|
2026-10-24 09:35:45 +02:00
|
|
|
raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
|
|
|
|
|
"Origin": evil,
|
|
|
|
|
"Content-Type": "text/plain",
|
2026-10-24 11:15:35 +02:00
|
|
|
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
|
2026-10-24 09:35:45 +02:00
|
|
|
if raw.status != http.StatusForbidden {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// None of it had any effect: no hit at the provider, not muted.
|
2026-10-24 09:35:45 +02:00
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
if st := getStatus(t, d); st.Muted {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon is muted — browser POST /mute took effect")
|
2026-10-24 09:35:45 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("cors = %v, want both listed origins", st.Cors)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("empty = [] (not null)", func(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("cors = %v, want [] with the door closed", st.Cors)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|