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) } }) }