diff --git a/api/handler/cors.go b/api/handler/cors.go index d0c8d876..174d646a 100644 --- a/api/handler/cors.go +++ b/api/handler/cors.go @@ -18,7 +18,7 @@ func applyCORSHeaders(apiResponse *events.APIGatewayProxyResponse, origin string apiResponse.Headers = map[string]string{ "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, " + TurnstileTokenHeader, + "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Credentials": "true", "Access-Control-Expose-Headers": maintenanceModeHeader + ", " + maintenanceMessageHeader + ", " + noticeMessageHeader, "Vary": "Origin", diff --git a/api/handler/session.go b/api/handler/session.go index a3448d0a..3baafe96 100644 --- a/api/handler/session.go +++ b/api/handler/session.go @@ -14,9 +14,11 @@ import ( "github.com/aws/aws-lambda-go/events" ) -// TurnstileTokenHeader carries a one-time Cloudflare Turnstile response on GET /session. +// TurnstileTokenQueryParam carries a one-time Cloudflare Turnstile response on GET /session. +// Query string avoids a CORS preflight for a custom header; API Gateway OPTIONS does not +// forward preflights to Lambda with our current CORS wiring. // TODO(api-abuse): migrate to POST /session with a JSON body once API Gateway exposes POST. -const TurnstileTokenHeader = "X-Turnstile-Token" +const TurnstileTokenQueryParam = "turnstileToken" var sessionTokenFunc = apiauth.NewSessionToken var turnstileVerifyFunc = apiauth.VerifyTurnstileToken @@ -72,11 +74,13 @@ func parseSessionTurnstileToken(request events.APIGatewayProxyRequest) (string, return "", nil } - token := strings.TrimSpace(headerValue(request.Headers, strings.ToLower(TurnstileTokenHeader))) - if token == "" { - return "", errSessionVerificationRequired + if request.QueryStringParameters != nil { + token := strings.TrimSpace(request.QueryStringParameters[TurnstileTokenQueryParam]) + if token != "" { + return token, nil + } } - return token, nil + return "", errSessionVerificationRequired } func enforceTurnstile( diff --git a/api/handler/session_test.go b/api/handler/session_test.go index f55f9d58..28329f02 100644 --- a/api/handler/session_test.go +++ b/api/handler/session_test.go @@ -77,8 +77,10 @@ func TestSession_MintsWithVerifiedTurnstileToken(t *testing.T) { req := events.APIGatewayProxyRequest{ HTTPMethod: http.MethodGet, Headers: map[string]string{ - "origin": "http://localhost:5173", - TurnstileTokenHeader: "good-token", + "origin": "http://localhost:5173", + }, + QueryStringParameters: map[string]string{ + TurnstileTokenQueryParam: "good-token", }, RequestContext: events.APIGatewayProxyRequestContext{ Identity: events.APIGatewayRequestIdentity{ @@ -106,8 +108,10 @@ func TestSession_RejectsFailedTurnstileVerification(t *testing.T) { req := events.APIGatewayProxyRequest{ HTTPMethod: http.MethodGet, Headers: map[string]string{ - "origin": "http://localhost:5173", - TurnstileTokenHeader: "bad-token", + "origin": "http://localhost:5173", + }, + QueryStringParameters: map[string]string{ + TurnstileTokenQueryParam: "bad-token", }, } @@ -127,8 +131,10 @@ func TestSession_RejectsPostWhenTurnstileConfigured(t *testing.T) { req := events.APIGatewayProxyRequest{ HTTPMethod: http.MethodPost, Headers: map[string]string{ - "origin": "http://localhost:5173", - TurnstileTokenHeader: "good-token", + "origin": "http://localhost:5173", + }, + QueryStringParameters: map[string]string{ + TurnstileTokenQueryParam: "good-token", }, } diff --git a/docs/api-abuse-mitigation.md b/docs/api-abuse-mitigation.md index 516fff30..29709149 100644 --- a/docs/api-abuse-mitigation.md +++ b/docs/api-abuse-mitigation.md @@ -176,13 +176,17 @@ cannot search when this layer is on. 1. Origin verification (layer 1) must pass. 2. `API_SESSION_SECRET` must be set; otherwise **503** (`session not configured`). 3. When `TURNSTILE_SECRET_KEY` is set, minting requires **`GET /session`** with a - valid Cloudflare Turnstile token in the `X-Turnstile-Token` header. Requests - without the header return **400** (`verification required`). The SPA runs - invisible Turnstile on every mint and background refresh when + valid Cloudflare Turnstile token in the `turnstileToken` query parameter. + Requests without the token return **400** (`verification required`). The SPA + runs invisible Turnstile on every mint and background refresh when `VITE_TURNSTILE_SITE_KEY` is configured. + The token is sent as a query param (not a custom header) so browsers do not + require a CORS preflight that API Gateway currently does not answer with + `X-Turnstile-Token` allowed. + > **TODO:** migrate to `POST /session` with a JSON body once API Gateway exposes - > `POST` on `/session` (keeps tokens out of access logs and preflight headers). + > `POST` on `/session` (keeps tokens out of access logs). After siteverify succeeds, Lambda checks the response `hostname` matches the SPA origin (`gishathfetch.com`, or `localhost` when `ENV` is not `prod`). Tokens @@ -219,7 +223,7 @@ When `API_SESSION_SECRET` is set, `/search` requires a valid cookie: - `ensureApiSession()` (`frontend/src/utils/apiSession.js`) mints the cookie before search (and shares one in-flight mint). - When Turnstile is enabled, each mint runs invisible Turnstile via - `frontend/src/utils/turnstile.js` and sends the token in `X-Turnstile-Token` + `frontend/src/utils/turnstile.js` and sends the token as `?turnstileToken=` on `GET /session`. - Background refresh every **10 minutes** (`API_SESSION_REFRESH_INTERVAL_MS`) so idle tabs stay under the 15-minute TTL. @@ -294,7 +298,7 @@ GitHub Actions secrets, or a local `.env` file (gitignored). See also | `API_ORIGIN_VERIFY_HEADER` | Lambda | `X-Origin-Verify` | Custom header name for the shared secret | | `API_SESSION_SECRET` | Lambda | unset = skip session on `/search`; `/session` 503 | Sign/validate `gf_api_session` | | `API_SESSION_TTL_SECONDS` | Lambda | `900` | Cookie / token lifetime | -| `TURNSTILE_SECRET_KEY` | Lambda | unset = `GET /session` unchanged | Require `X-Turnstile-Token` on `GET /session` | +| `TURNSTILE_SECRET_KEY` | Lambda | unset = `GET /session` unchanged | Require `turnstileToken` query param on `GET /session` | | `VITE_TURNSTILE_SITE_KEY` | Frontend | unset = Turnstile skipped in dev | Invisible Turnstile before each session mint | | `API_MAINTENANCE_MODE` | Lambda | unset/`false` = off | `/search` returns **503**; `/session` advertises maintenance headers | | `API_MAINTENANCE_MESSAGE` | Lambda | generic unavailable message | User-visible banner text while maintenance mode is on | diff --git a/frontend/src/constants.js b/frontend/src/constants.js index d5f9bc32..c46bac48 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -220,8 +220,8 @@ export const TURNSTILE_SITE_KEY = ? import.meta.env.VITE_TURNSTILE_SITE_KEY.trim() : ""; -/** Header sent with GET /session when Turnstile is enabled. */ -export const TURNSTILE_TOKEN_HEADER = "X-Turnstile-Token"; +/** Query param sent with GET /session when Turnstile is enabled. */ +export const TURNSTILE_TOKEN_QUERY_PARAM = "turnstileToken"; export const BASE_URL = "https://gishathfetch.com/"; diff --git a/frontend/src/hooks/useSearch.js b/frontend/src/hooks/useSearch.js index 1ddf7285..16033b2c 100644 --- a/frontend/src/hooks/useSearch.js +++ b/frontend/src/hooks/useSearch.js @@ -746,29 +746,40 @@ export default function useSearch() { }, []); // --- Initialization --- - // Note: performSearch is included in deps but is stable (empty dep array in useCallback) - // This effect should only run once on mount, not when selectedStores changes - const hasInitializedRef = useRef(false); + // Run a deep-linked ?s= search once session bootstrap (incl. Turnstile) completes. + const landingSearchHandledRef = useRef(false); - useEffect(() => { - if (!sessionBootstrapped || hasInitializedRef.current) return; - hasInitializedRef.current = true; - - if (maintenanceMode) { + const runLandingSearchIfNeeded = useCallback(() => { + if (landingSearchHandledRef.current || maintenanceMode) { return; } const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has("s") && urlParams.get("s") !== "") { - const q = decodeURIComponent(urlParams.get("s")); - skipSuggestionsRef.current = true; + if (!urlParams.has("s") || urlParams.get("s") === "") { + landingSearchHandledRef.current = true; + return; + } - const urlStores = getStoresFromUrl(urlParams); - const stores = urlStores ?? selectedStores; + const q = decodeURIComponent(urlParams.get("s")); + if (q.length < MIN_SEARCH_LENGTH || q.length > MAX_SEARCH_LENGTH) { + landingSearchHandledRef.current = true; + return; + } + + landingSearchHandledRef.current = true; + skipSuggestionsRef.current = true; - setTimeout(() => performSearch(q, stores), 100); + const urlStores = getStoresFromUrl(urlParams); + const stores = urlStores ?? getInitialSelectedStores(urlParams); + performSearch(q, stores); + }, [maintenanceMode, performSearch]); + + useEffect(() => { + if (!sessionBootstrapped) { + return; } - }, [sessionBootstrapped, maintenanceMode, performSearch, selectedStores]); + runLandingSearchIfNeeded(); + }, [sessionBootstrapped, runLandingSearchIfNeeded]); return { searchQuery, diff --git a/frontend/src/utils/apiSession.js b/frontend/src/utils/apiSession.js index 47fb0d29..f52fa57e 100644 --- a/frontend/src/utils/apiSession.js +++ b/frontend/src/utils/apiSession.js @@ -1,7 +1,7 @@ import { API_SESSION_URL, TURNSTILE_SITE_KEY, - TURNSTILE_TOKEN_HEADER, + TURNSTILE_TOKEN_QUERY_PARAM, } from "../constants"; import { isTurnstileEnabled, requestTurnstileToken } from "./turnstile"; @@ -179,15 +179,18 @@ async function mintApiSession() { credentials: "include", }; + let sessionUrl = API_SESSION_URL; if (isTurnstileEnabled(TURNSTILE_SITE_KEY)) { const turnstileToken = await requestTurnstileToken(TURNSTILE_SITE_KEY); - // TODO(api-abuse): migrate to POST /session once API Gateway exposes POST. - fetchOptions.headers = { - [TURNSTILE_TOKEN_HEADER]: turnstileToken, - }; + const params = new URLSearchParams({ + [TURNSTILE_TOKEN_QUERY_PARAM]: turnstileToken, + }); + // Query param avoids CORS preflight for X-Turnstile-Token (API Gateway OPTIONS + // does not allow that header today). TODO(api-abuse): POST /session when available. + sessionUrl = `${API_SESSION_URL}?${params.toString()}`; } - const res = await fetch(API_SESSION_URL, fetchOptions); + const res = await fetch(sessionUrl, fetchOptions); const sessionMintDurationMs = Math.round( performance.now() - sessionMintStart, ); diff --git a/frontend/src/utils/turnstile.js b/frontend/src/utils/turnstile.js index 5a7576f7..04ff462d 100644 --- a/frontend/src/utils/turnstile.js +++ b/frontend/src/utils/turnstile.js @@ -62,7 +62,7 @@ function ensureWidget(siteKey) { /** * Runs invisible Turnstile and returns a one-time token for GET /session - * (sent in the X-Turnstile-Token header). Safe to call on every session mint + * (sent as the turnstileToken query param). Safe to call on every session mint * and background refresh. */ export async function requestTurnstileToken(siteKey) {