@@ -1491,17 +1491,26 @@ function forbiddenResponse() {
14911491 ) ;
14921492} /**
14931493 * Web-standard HTTP handler for server function calls: resolves the
1494- * function id from the request, gates GET dispatch on the declaration (405
1495- * for a GET request to a function that never declared `GET`; POST is always
1496- * accepted), decodes arguments, runs the function under a request-event scope,
1497- * and encodes the result (forwarding redirect/revalidation metadata
1498- * through headers). Mount it on the endpoint the client transport targets
1499- * (default `/_server`); platform adapters (h3, express, ...) convert their
1500- * request shape to a web `Request` around it.
1494+ * function id from the request, enforces the method allowlist (POST always
1495+ * dispatches; GET and HEAD dispatch only to functions that declared `GET`,
1496+ * with HEAD returning the equivalent GET's status and headers minus the
1497+ * body; every other method answers 405), decodes arguments, runs the
1498+ * function under a request-event scope, and encodes the result (forwarding
1499+ * redirect/revalidation metadata through headers). Mount it on the endpoint
1500+ * the client transport targets (default `/_server`); platform adapters (h3,
1501+ * express, ...) convert their request shape to a web `Request` around it.
15011502 *
15021503 * Requests are same-origin by default. The handler accepts browser requests
15031504 * proven by `Sec-Fetch-Site`, `Origin`, or `Referer`, and rejects requests
1504- * without usable metadata unless explicitly configured otherwise.
1505+ * without usable metadata unless explicitly configured otherwise. GET/HEAD
1506+ * requests to `GET`-declared functions skip this gate: they are reads by
1507+ * contract, cross-site response READING is already blocked by same-origin
1508+ * policy, and skipping it keeps the `Vary: Sec-Fetch-Site, Origin, Referer`
1509+ * it would impose off the responses shared caches are meant to store.
1510+ *
1511+ * Every response leaves with `Cache-Control: no-store` unless the function
1512+ * set its own cache policy (via `respond()` headers or a returned
1513+ * `Response`) — caching is opt-in on the wire, not just in prose.
15051514 *
15061515 * When the event carries a `response` head stub (`event.response`, see the
15071516 * server entry's `ResponseStub`), the handler folds it onto every outgoing
@@ -1608,17 +1617,28 @@ export function handleServerFunctionRequest(
16081617export async function handleServerFunctionRequest ( request , options = { } ) {
16091618 const codec = options . codec !== undefined ? options . codec : getServerFunctionsCodec ( ) ;
16101619 const url = new URL ( request . url ) ;
1620+ const method = request . method ;
1621+ const functionId = resolveFunctionId ( request , url ) ;
1622+ // GET-declared functions are reads by contract, and the read methods are
1623+ // exactly where the CSRF gate costs more than it buys: same-origin policy
1624+ // already prevents a cross-site caller from READING the response, while
1625+ // the gate's `Vary: Sec-Fetch-Site, Origin, Referer` fragments (or, on
1626+ // CDNs that ignore Vary, poisons) the shared-cache entries the GET helper
1627+ // exists to enable (#3071). State-changing dispatch (POST) stays gated.
1628+ const declaredRead =
1629+ ( method === "GET" || method === "HEAD" ) &&
1630+ functionId !== null &&
1631+ METHODS . get ( functionId ) === "GET" ;
16111632 const csrf = options . csrf !== undefined ? options . csrf : config . csrf ;
1612- const protectsRequest = csrf !== false ;
1633+ const protectsRequest = csrf !== false && ! declaredRead ;
16131634 if ( protectsRequest && ! ( await allowsServerFunctionRequest ( request , csrf === true ? { } : csrf ) ) ) {
1614- return forbiddenResponse ( ) ;
1635+ return finalizeTransportResponse ( forbiddenResponse ( ) , method ) ;
16151636 }
16161637 const instance = request . headers . get ( INSTANCE_HEADER ) ;
1617- const functionId = resolveFunctionId ( request , url ) ;
16181638
16191639 if ( ! functionId ) {
16201640 const response = new Response ( DEV ? "Server function not found" : null , { status : 404 } ) ;
1621- return protectsRequest ? withCSRFVary ( response ) : response ;
1641+ return finalizeTransportResponse ( protectsRequest ? withCSRFVary ( response ) : response , method ) ;
16221642 }
16231643
16241644 let serverFunction ;
@@ -1628,24 +1648,27 @@ export async function handleServerFunctionRequest(request, options = {}) {
16281648 const response = new Response ( DEV ? `Unknown server function: ${ functionId } ` : null , {
16291649 status : 404
16301650 } ) ;
1631- return protectsRequest ? withCSRFVary ( response ) : response ;
1651+ return finalizeTransportResponse ( protectsRequest ? withCSRFVary ( response ) : response , method ) ;
16321652 }
16331653
1634- // method enforcement: GET requests only dispatch to functions that
1635- // declared GET (the server half of `GET` records them) — no crafted GET
1636- // URLs against functions that never opted in. Declaring GET grants GET
1637- // without revoking POST: the same function stays callable over the
1638- // default transport (e.g. a query()-wrapped function also called
1639- // directly).
1640- if ( request . method === "GET" && METHODS . get ( functionId ) !== "GET" ) {
1654+ // Method allowlist: POST always dispatches (the default transport);
1655+ // GET and HEAD dispatch only to functions that declared GET (the server
1656+ // half of `GET` records them) — no crafted read URLs against functions
1657+ // that never opted in, and no side door through OTHER verbs either
1658+ // (before #3069 a HEAD — sent freely by link checkers, uptime probes and
1659+ // prefetchers — bypassed the gate entirely and executed any registered
1660+ // function). Declaring GET grants the read methods without revoking POST:
1661+ // the same function stays callable over the default transport (e.g. a
1662+ // query()-wrapped function also called directly).
1663+ if ( method !== "POST" && ! declaredRead ) {
16411664 const response = new Response (
16421665 DEV ? `Method not allowed for server function: ${ functionId } ` : null ,
16431666 {
16441667 status : 405 ,
1645- headers : { Allow : "POST" }
1668+ headers : { Allow : METHODS . get ( functionId ) === "GET" ? "POST, GET, HEAD" : "POST" }
16461669 }
16471670 ) ;
1648- return protectsRequest ? withCSRFVary ( response ) : response ;
1671+ return finalizeTransportResponse ( protectsRequest ? withCSRFVary ( response ) : response , method ) ;
16491672 }
16501673
16511674 const event = options . createEvent ? options . createEvent ( request ) : { request, locals : { } } ;
@@ -1875,5 +1898,47 @@ export async function handleServerFunctionRequest(request, options = {}) {
18751898 }
18761899 } ;
18771900 const response = commitEventResponse ( await dispatch ( ) , event ) ;
1878- return protectsRequest ? withCSRFVary ( response ) : response ;
1901+ return finalizeTransportResponse ( protectsRequest ? withCSRFVary ( response ) : response , method ) ;
1902+ }
1903+
1904+ /**
1905+ * Last-mile transport hygiene applied to every response leaving the handler.
1906+ *
1907+ * - `Cache-Control: no-store` unless the function set its own (via
1908+ * `respond()` headers or a returned Response): caching is opt-in ON THE
1909+ * WIRE the way the docs describe it in prose. Without the default, CDN
1910+ * zones with override-TTL or "cache everything" rules store per-user RPC
1911+ * responses (#3071).
1912+ * - HEAD responses drop their body, as HTTP requires — the function still
1913+ * ran (HEAD is gated identically to GET), so status and headers are those
1914+ * of the equivalent GET (#3069).
1915+ */
1916+ function finalizeTransportResponse ( response , method ) {
1917+ const stripBody = method === "HEAD" && response . body !== null ;
1918+ if ( stripBody || ! response . headers . has ( "Cache-Control" ) ) {
1919+ try {
1920+ if ( ! response . headers . has ( "Cache-Control" ) ) {
1921+ response . headers . set ( "Cache-Control" , "no-store" ) ;
1922+ }
1923+ if ( ! stripBody ) return response ;
1924+ // discard, don't leak: the encoded body may be a live codec stream
1925+ response . body . cancel ( ) . catch ( ( ) => { } ) ;
1926+ return new Response ( null , {
1927+ status : response . status ,
1928+ statusText : response . statusText ,
1929+ headers : response . headers
1930+ } ) ;
1931+ } catch {
1932+ // immutable headers (e.g. a raw fetch() Response passed through)
1933+ const headers = new Headers ( response . headers ) ;
1934+ if ( ! headers . has ( "Cache-Control" ) ) headers . set ( "Cache-Control" , "no-store" ) ;
1935+ if ( stripBody ) response . body . cancel ( ) . catch ( ( ) => { } ) ;
1936+ return new Response ( stripBody ? null : response . body , {
1937+ status : response . status ,
1938+ statusText : response . statusText ,
1939+ headers
1940+ } ) ;
1941+ }
1942+ }
1943+ return response ;
18791944}
0 commit comments