Keep the CSRF token on same-origin API calls and lint colours in the build (#45)
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m31s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #45.
This commit is contained in:
2026-10-04 09:08:42 -04:00
committed by approver_agent
parent 048d5dd71a
commit 5758f198e4
4 changed files with 52 additions and 8 deletions
+24
View File
@@ -34,6 +34,30 @@ describe('apiFetch', () => {
expect(fetchMock).not.toHaveBeenCalled()
})
it.each([
'https://evil.example/api/v1/me',
'//evil.example/api/v1/me',
// The placeholder origin an earlier guard compared against.
'//same-origin.invalid/api/v1/me',
'https://same-origin.invalid/api/v1/me',
'/\\evil.example/api/v1/me',
'/auth/logout',
'/api/v1/../auth/logout',
'/api/v1/%2e%2e/auth/logout',
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation())
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
`POST ${path}: not a same-origin /api/v1/ path`,
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
})
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation())
+24 -5
View File
@@ -5,6 +5,22 @@ export const CSRF_HEADER = 'X-MealQuest-CSRF'
export const ME_PATH = '/api/v1/me'
// API_PREFIX is the only path apiFetch sends to.
const API_PREFIX = '/api/v1/'
// apiURL resolves path against the page origin and returns it as the
// path-absolute URL to fetch. It refuses any path that would not resolve to a
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
// else: absolute and protocol-relative URLs on another origin, and dot
// segments that climb out of /api/v1/.
function apiURL(origin: string, method: string, path: string): string {
const url = new URL(path, origin)
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
}
return url.pathname + url.search
}
// Me is the GET /api/v1/me response: the signed-in member, the household and
// the session's CSRF token.
export type Me = {
@@ -15,7 +31,8 @@ export type Me = {
}
// ApiLocation is the part of window.location the client reads and navigates.
export type ApiLocation = Pick<Location, 'pathname' | 'search' | 'assign'>
// origin is the page origin every request must stay on.
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
@@ -24,8 +41,9 @@ export type ApiRequest = {
}
export type ApiClient = {
// apiFetch sends a same-origin JSON request to path and resolves with the
// decoded response body, or rejects with an ApiError.
// apiFetch sends a same-origin JSON request to path, which must be under
// /api/v1/, and resolves with the decoded response body, or rejects with an
// ApiError.
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
@@ -71,14 +89,15 @@ export function createApiClient(location: ApiLocation): ApiClient {
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
const method = request.method ?? 'GET'
let response = await send(method, path, request.body)
const url = apiURL(location.origin, method, path)
let response = await send(method, url, request.body)
if (response.status === 403) {
const error = await parseForbidden(method, path, response)
// Only a non-GET carries the token, so only it can be refused for it.
if (error.code !== 'csrf' || method === 'GET') throw error
// The session's token changed: refresh it once and retry once.
await refreshMe()
response = await send(method, path, request.body)
response = await send(method, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return decode<T>(method, path, response)
+3 -2
View File
@@ -42,7 +42,8 @@ export function request(fetchMock: Mock<typeof fetch>, n: number) {
}
}
// fakeLocation stands in for window.location, recording navigations.
// fakeLocation stands in for window.location on https://mealquest.test,
// recording navigations.
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
return { pathname, search, assign: vi.fn<(url: string | URL) => void>() }
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
}