Skip to content

Commit d0edaac

Browse files
ztannergnoff
andcommitted
Backport Next.js changes to v15.4.9 (#24)
* remove urlencoded server action handling (#14) * remove urlencoded server action handling * urlencoded POSTs should still flow through action handler * bailout early for urlencoded * tweak handling to 404 in feetch action case * error when there are no server actions (#15) * remove custom error * remove dev gate * error when there are no server actions * gate against dev * Check whether action ids are valid before parsing (#16) * Check whether action ids are valid before parsing In an effort to narrow access to parsing methods Next.js now prequalifies MPA form parsing by ensuring there are only valid action IDs as part of the submission. This is not meant to be a strong line of defense against malicious payloads since action IDs are not private and are for many apps relatively easy to acquire. It does however provide some limited narrowing of code paths so that action decoding only happens for plausible actions Additionally all other branches that use next-action header now consistently check the header before proceeding with parsing. This is also a perf improvement. * fix test assertion --------- Co-authored-by: Zack Tanner <1939140+ztanner@users.noreply.github.com> --------- Co-authored-by: Josh Story <story@hey.com>
1 parent bd84546 commit d0edaac

File tree

7 files changed

+276
-76
lines changed

7 files changed

+276
-76
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,5 +720,8 @@
720720
"719": "Failed to get source map for '%s'. This is a bug in Next.js",
721721
"720": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
722722
"721": "Client Max Body Size must be larger than 0 bytes",
723-
"722": "Request body exceeded %s"
723+
"722": "Request body exceeded %s",
724+
"723": "Server Actions are not enabled for this application. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
725+
"724": "Failed to find Server Action. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
726+
"725": "Failed to find Server Action%s. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action"
724727
}

packages/next/src/server/app-render/action-handler.ts

Lines changed: 203 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RequestStore } from '../app-render/work-unit-async-storage.externa
44
import type { AppRenderContext, GenerateFlight } from './app-render'
55
import type { AppPageModule } from '../route-modules/app-page/module'
66
import type { BaseNextRequest, BaseNextResponse } from '../base-http'
7+
import type { ActionManifest } from '../../build/webpack/plugins/flight-client-entry-plugin'
78

89
import {
910
RSC_HEADER,
@@ -59,13 +60,14 @@ import { executeRevalidates } from '../revalidation-utils'
5960
import { getRequestMeta } from '../request-meta'
6061
import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param'
6162

62-
function formDataFromSearchQueryString(query: string) {
63-
const searchParams = new URLSearchParams(query)
64-
const formData = new FormData()
65-
for (const [key, value] of searchParams) {
66-
formData.append(key, value)
67-
}
68-
return formData
63+
/**
64+
* Checks if the app has any server actions defined in any runtime.
65+
*/
66+
function hasServerActions(manifest: ActionManifest) {
67+
return (
68+
Object.keys(manifest.node).length > 0 ||
69+
Object.keys(manifest.edge).length > 0
70+
)
6971
}
7072

7173
function nodeHeadersToRecord(
@@ -508,19 +510,55 @@ export async function handleAction({
508510

509511
const {
510512
actionId,
511-
isURLEncodedAction,
512513
isMultipartAction,
513514
isFetchAction,
515+
isURLEncodedAction,
514516
isPossibleServerAction,
515517
} = getServerActionRequestMetadata(req)
516518

519+
const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => {
520+
// If the deployment doesn't have skew protection, this is expected to occasionally happen,
521+
// so we use a warning instead of an error.
522+
console.warn(err)
523+
524+
// Return an empty response with a header that the client router will interpret.
525+
// We don't need to waste time encoding a flight response, and using a blank body + header
526+
// means that unrecognized actions can also be handled at the infra level
527+
// (i.e. without needing to invoke a lambda)
528+
res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1')
529+
res.setHeader('content-type', 'text/plain')
530+
res.statusCode = 404
531+
return {
532+
type: 'done',
533+
result: RenderResult.fromStatic('Server action not found.'),
534+
}
535+
}
536+
517537
// If it can't be a Server Action, skip handling.
518538
// Note that this can be a false positive -- any multipart/urlencoded POST can get us here,
519539
// But won't know if it's an MPA action or not until we call `decodeAction` below.
520540
if (!isPossibleServerAction) {
521541
return null
522542
}
523543

544+
// We don't currently support URL encoded actions, so we bail out early.
545+
// Depending on if it's a fetch action or an MPA, we return a different response.
546+
if (isURLEncodedAction) {
547+
if (isFetchAction) {
548+
return {
549+
type: 'not-found',
550+
}
551+
} else {
552+
// This is an MPA action, so we return null
553+
return null
554+
}
555+
}
556+
557+
// If the app has no server actions at all, we can 404 early.
558+
if (!hasServerActions(serverActionsManifest)) {
559+
return handleUnrecognizedFetchAction(getActionNotFoundError(actionId))
560+
}
561+
524562
if (workStore.isStaticGeneration) {
525563
throw new Error(
526564
"Invariant: server actions can't be handled during static rendering"
@@ -641,24 +679,6 @@ export async function handleAction({
641679
}
642680
}
643681

644-
const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => {
645-
// If the deployment doesn't have skew protection, this is expected to occasionally happen,
646-
// so we use a warning instead of an error.
647-
console.warn(err)
648-
649-
// Return an empty response with a header that the client router will interpret.
650-
// We don't need to waste time encoding a flight response, and using a blank body + header
651-
// means that unrecognized actions can also be handled at the infra level
652-
// (i.e. without needing to invoke a lambda)
653-
res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1')
654-
res.setHeader('content-type', 'text/plain')
655-
res.statusCode = 404
656-
return {
657-
type: 'done',
658-
result: RenderResult.fromStatic('Server action not found.'),
659-
}
660-
}
661-
662682
try {
663683
return await actionAsyncStorage.run(
664684
{ isAction: true },
@@ -694,6 +714,13 @@ export async function handleAction({
694714
const formData = await req.request.formData()
695715
if (isFetchAction) {
696716
// A fetch action with a multipart body.
717+
718+
try {
719+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
720+
} catch (err) {
721+
return handleUnrecognizedFetchAction(err)
722+
}
723+
697724
boundActionArguments = await decodeReply(
698725
formData,
699726
serverModuleMap,
@@ -702,6 +729,14 @@ export async function handleAction({
702729
} else {
703730
// Multipart POST, but not a fetch action.
704731
// Potentially an MPA action, we have to try decoding it to check.
732+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
733+
// TODO: This can be from skew or manipulated input. We should handle this case
734+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
735+
throw new Error(
736+
`Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
737+
)
738+
}
739+
705740
const action = await decodeAction(formData, serverModuleMap)
706741
if (typeof action === 'function') {
707742
// an MPA action.
@@ -767,20 +802,11 @@ export async function handleAction({
767802

768803
const actionData = Buffer.concat(chunks).toString('utf-8')
769804

770-
if (isURLEncodedAction) {
771-
const formData = formDataFromSearchQueryString(actionData)
772-
boundActionArguments = await decodeReply(
773-
formData,
774-
serverModuleMap,
775-
{ temporaryReferences }
776-
)
777-
} else {
778-
boundActionArguments = await decodeReply(
779-
actionData,
780-
serverModuleMap,
781-
{ temporaryReferences }
782-
)
783-
}
805+
boundActionArguments = await decodeReply(
806+
actionData,
807+
serverModuleMap,
808+
{ temporaryReferences }
809+
)
784810
}
785811
} else if (
786812
// The type check here ensures that `req` is correctly typed, and the
@@ -848,6 +874,12 @@ export async function handleAction({
848874
if (isFetchAction) {
849875
// A fetch action with a multipart body.
850876

877+
try {
878+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
879+
} catch (err) {
880+
return handleUnrecognizedFetchAction(err)
881+
}
882+
851883
const busboy = (
852884
require('next/dist/compiled/busboy') as typeof import('next/dist/compiled/busboy')
853885
)({
@@ -895,7 +927,19 @@ export async function handleAction({
895927
}),
896928
duplex: 'half',
897929
})
930+
898931
const formData = await fakeRequest.formData()
932+
933+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
934+
// TODO: This can be from skew or manipulated input. We should handle this case
935+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
936+
throw new Error(
937+
`Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
938+
)
939+
}
940+
941+
// TODO: Refactor so it is harder to accidentally decode an action before you have validated that the
942+
// action referred to is available.
899943
const action = await decodeAction(formData, serverModuleMap)
900944
if (typeof action === 'function') {
901945
// an MPA action.
@@ -955,20 +999,11 @@ export async function handleAction({
955999

9561000
const actionData = Buffer.concat(chunks).toString('utf-8')
9571001

958-
if (isURLEncodedAction) {
959-
const formData = formDataFromSearchQueryString(actionData)
960-
boundActionArguments = await decodeReply(
961-
formData,
962-
serverModuleMap,
963-
{ temporaryReferences }
964-
)
965-
} else {
966-
boundActionArguments = await decodeReply(
967-
actionData,
968-
serverModuleMap,
969-
{ temporaryReferences }
970-
)
971-
}
1002+
boundActionArguments = await decodeReply(
1003+
actionData,
1004+
serverModuleMap,
1005+
{ temporaryReferences }
1006+
)
9721007
}
9731008
} else {
9741009
throw new Error('Invariant: Unknown request type.')
@@ -986,13 +1021,6 @@ export async function handleAction({
9861021
// / -> fire action -> POST / -> appRender1 -> modId for the action file
9871022
// /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file
9881023

989-
try {
990-
actionModId =
991-
actionModId ?? getActionModIdOrError(actionId, serverModuleMap)
992-
} catch (err) {
993-
return handleUnrecognizedFetchAction(err)
994-
}
995-
9961024
const actionMod = (await ComponentMod.__next_app__.require(
9971025
actionModId
9981026
)) as Record<string, (...args: unknown[]) => Promise<unknown>>
@@ -1178,10 +1206,121 @@ function getActionModIdOrError(
11781206
const actionModId = serverModuleMap[actionId]?.id
11791207

11801208
if (!actionModId) {
1181-
throw new Error(
1182-
`Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
1183-
)
1209+
throw getActionNotFoundError(actionId)
11841210
}
11851211

11861212
return actionModId
11871213
}
1214+
1215+
function getActionNotFoundError(actionId: string | null): Error {
1216+
return new Error(
1217+
`Failed to find Server Action${actionId ? ` "${actionId}"` : ''}. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
1218+
)
1219+
}
1220+
1221+
const $ACTION_ = '$ACTION_'
1222+
const $ACTION_REF_ = '$ACTION_REF_'
1223+
const $ACTION_ID_ = '$ACTION_ID_'
1224+
const ACTION_ID_EXPECTED_LENGTH = 42
1225+
1226+
/**
1227+
* This function mirrors logic inside React's decodeAction and should be kept in sync with that.
1228+
* It pre-parses the FormData to ensure that any action IDs referred to are actual action IDs for
1229+
* this Next.js application.
1230+
*/
1231+
function areAllActionIdsValid(
1232+
mpaFormData: FormData,
1233+
serverModuleMap: ServerModuleMap
1234+
): boolean {
1235+
let hasAtLeastOneAction = false
1236+
// Before we attempt to decode the payload for a possible MPA action, assert that all
1237+
// action IDs are valid IDs. If not we should disregard the payload
1238+
for (let key of mpaFormData.keys()) {
1239+
if (!key.startsWith($ACTION_)) {
1240+
// not a relevant field
1241+
continue
1242+
}
1243+
1244+
if (key.startsWith($ACTION_ID_)) {
1245+
// No Bound args case
1246+
if (isInvalidActionIdFieldName(key, serverModuleMap)) {
1247+
return false
1248+
}
1249+
1250+
hasAtLeastOneAction = true
1251+
} else if (key.startsWith($ACTION_REF_)) {
1252+
// Bound args case
1253+
const actionDescriptorField =
1254+
$ACTION_ + key.slice($ACTION_REF_.length) + ':0'
1255+
const actionFields = mpaFormData.getAll(actionDescriptorField)
1256+
if (actionFields.length !== 1) {
1257+
return false
1258+
}
1259+
const actionField = actionFields[0]
1260+
if (typeof actionField !== 'string') {
1261+
return false
1262+
}
1263+
1264+
if (isInvalidStringActionDescriptor(actionField, serverModuleMap)) {
1265+
return false
1266+
}
1267+
hasAtLeastOneAction = true
1268+
}
1269+
}
1270+
return hasAtLeastOneAction
1271+
}
1272+
1273+
const ACTION_DESCRIPTOR_ID_PREFIX = '{"id":"'
1274+
function isInvalidStringActionDescriptor(
1275+
actionDescriptor: string,
1276+
serverModuleMap: ServerModuleMap
1277+
): unknown {
1278+
if (actionDescriptor.startsWith(ACTION_DESCRIPTOR_ID_PREFIX) === false) {
1279+
return true
1280+
}
1281+
1282+
const from = ACTION_DESCRIPTOR_ID_PREFIX.length
1283+
const to = from + ACTION_ID_EXPECTED_LENGTH
1284+
1285+
// We expect actionDescriptor to be '{"id":"<actionId>",...}'
1286+
const actionId = actionDescriptor.slice(from, to)
1287+
if (
1288+
actionId.length !== ACTION_ID_EXPECTED_LENGTH ||
1289+
actionDescriptor[to] !== '"'
1290+
) {
1291+
return true
1292+
}
1293+
1294+
const entry = serverModuleMap[actionId]
1295+
1296+
if (entry == null) {
1297+
return true
1298+
}
1299+
1300+
return false
1301+
}
1302+
1303+
function isInvalidActionIdFieldName(
1304+
actionIdFieldName: string,
1305+
serverModuleMap: ServerModuleMap
1306+
): boolean {
1307+
// The field name must always start with $ACTION_ID_ but since it is
1308+
// the id is extracted from the key of the field we have already validated
1309+
// this before entering this function
1310+
if (
1311+
actionIdFieldName.length !==
1312+
$ACTION_ID_.length + ACTION_ID_EXPECTED_LENGTH
1313+
) {
1314+
// this field name has too few or too many characters
1315+
return true
1316+
}
1317+
1318+
const actionId = actionIdFieldName.slice($ACTION_ID_.length)
1319+
const entry = serverModuleMap[actionId]
1320+
1321+
if (entry == null) {
1322+
return true
1323+
}
1324+
1325+
return false
1326+
}

packages/next/src/server/lib/server-action-request-meta.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function getServerActionRequestMetadata(
2323
contentType = req.headers['content-type'] ?? null
2424
}
2525

26+
// We don't actually support URL encoded actions, and the action handler will bail out if it sees one.
27+
// But we still want it to flow through to the action handler, to prevent changes in behavior when a regular
28+
// page component tries to handle a POST.
2629
const isURLEncodedAction = Boolean(
2730
req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
2831
)

0 commit comments

Comments
 (0)