Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/handler/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 10 additions & 6 deletions api/handler/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 12 additions & 6 deletions api/handler/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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",
},
}

Expand All @@ -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",
},
}

Expand Down
16 changes: 10 additions & 6 deletions docs/api-abuse-mitigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/";

Expand Down
41 changes: 26 additions & 15 deletions frontend/src/hooks/useSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions frontend/src/utils/apiSession.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/utils/turnstile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading