Skip to content

Commit e01e589

Browse files
ztannergnoff
andcommitted
Backport Next.js changes to v15.5.8 (#23)
* 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 b2706db commit e01e589

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
@@ -783,5 +783,8 @@
783783
"782": "Failed to find the root directory of the project. This is a bug in Next.js.",
784784
"783": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
785785
"784": "Client Max Body Size must be larger than 0 bytes",
786-
"785": "Request body exceeded %s"
786+
"785": "Request body exceeded %s",
787+
"786": "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",
788+
"787": "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",
789+
"788": "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"
787790
}

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,
@@ -60,13 +61,14 @@ import { executeRevalidates } from '../revalidation-utils'
6061
import { getRequestMeta } from '../request-meta'
6162
import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param'
6263

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

7274
function nodeHeadersToRecord(
@@ -509,19 +511,55 @@ export async function handleAction({
509511

510512
const {
511513
actionId,
512-
isURLEncodedAction,
513514
isMultipartAction,
514515
isFetchAction,
516+
isURLEncodedAction,
515517
isPossibleServerAction,
516518
} = getServerActionRequestMetadata(req)
517519

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

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

645-
const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => {
646-
// If the deployment doesn't have skew protection, this is expected to occasionally happen,
647-
// so we use a warning instead of an error.
648-
console.warn(err)
649-
650-
// Return an empty response with a header that the client router will interpret.
651-
// We don't need to waste time encoding a flight response, and using a blank body + header
652-
// means that unrecognized actions can also be handled at the infra level
653-
// (i.e. without needing to invoke a lambda)
654-
res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1')
655-
res.setHeader('content-type', 'text/plain')
656-
res.statusCode = 404
657-
return {
658-
type: 'done',
659-
result: RenderResult.fromStatic('Server action not found.', 'text/plain'),
660-
}
661-
}
662-
663683
try {
664684
return await actionAsyncStorage.run(
665685
{ isAction: true },
@@ -695,6 +715,13 @@ export async function handleAction({
695715
const formData = await req.request.formData()
696716
if (isFetchAction) {
697717
// A fetch action with a multipart body.
718+
719+
try {
720+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
721+
} catch (err) {
722+
return handleUnrecognizedFetchAction(err)
723+
}
724+
698725
boundActionArguments = await decodeReply(
699726
formData,
700727
serverModuleMap,
@@ -703,6 +730,14 @@ export async function handleAction({
703730
} else {
704731
// Multipart POST, but not a fetch action.
705732
// Potentially an MPA action, we have to try decoding it to check.
733+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
734+
// TODO: This can be from skew or manipulated input. We should handle this case
735+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
736+
throw new Error(
737+
`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`
738+
)
739+
}
740+
706741
const action = await decodeAction(formData, serverModuleMap)
707742
if (typeof action === 'function') {
708743
// an MPA action.
@@ -768,20 +803,11 @@ export async function handleAction({
768803

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

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

878+
try {
879+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
880+
} catch (err) {
881+
return handleUnrecognizedFetchAction(err)
882+
}
883+
852884
const busboy = (
853885
require('next/dist/compiled/busboy') as typeof import('next/dist/compiled/busboy')
854886
)({
@@ -896,7 +928,19 @@ export async function handleAction({
896928
}),
897929
duplex: 'half',
898930
})
931+
899932
const formData = await fakeRequest.formData()
933+
934+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
935+
// TODO: This can be from skew or manipulated input. We should handle this case
936+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
937+
throw new Error(
938+
`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`
939+
)
940+
}
941+
942+
// TODO: Refactor so it is harder to accidentally decode an action before you have validated that the
943+
// action referred to is available.
900944
const action = await decodeAction(formData, serverModuleMap)
901945
if (typeof action === 'function') {
902946
// an MPA action.
@@ -956,20 +1000,11 @@ export async function handleAction({
9561000

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

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

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

11811209
if (!actionModId) {
1182-
throw new Error(
1183-
`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`
1184-
)
1210+
throw getActionNotFoundError(actionId)
11851211
}
11861212

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

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)