Skip to content

Commit eb3b8a8

Browse files
ztannergnoff
andcommitted
Backport Next.js changes to v15.6.0-canary.57 (#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 <[email protected]> --------- Co-authored-by: Josh Story <[email protected]>
1 parent c3ae401 commit eb3b8a8

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
@@ -871,5 +871,8 @@
871871
"870": "refresh can only be called from within a Server Action. See more info here: https://nextjs.org/docs/app/api-reference/functions/refresh",
872872
"871": "Image with src \"%s\" is using a query string which is not configured in images.localPatterns.\\nRead more: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns",
873873
"872": "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag",
874-
"873": "Invalid profile provided \"%s\" must be configured under cacheLife in next.config or be \"max\""
874+
"873": "Invalid profile provided \"%s\" must be configured under cacheLife in next.config or be \"max\"",
875+
"874": "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",
876+
"875": "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",
877+
"876": "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"
875878
}

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(
@@ -527,19 +529,55 @@ export async function handleAction({
527529

528530
const {
529531
actionId,
530-
isURLEncodedAction,
531532
isMultipartAction,
532533
isFetchAction,
534+
isURLEncodedAction,
533535
isPossibleServerAction,
534536
} = getServerActionRequestMetadata(req)
535537

538+
const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => {
539+
// If the deployment doesn't have skew protection, this is expected to occasionally happen,
540+
// so we use a warning instead of an error.
541+
console.warn(err)
542+
543+
// Return an empty response with a header that the client router will interpret.
544+
// We don't need to waste time encoding a flight response, and using a blank body + header
545+
// means that unrecognized actions can also be handled at the infra level
546+
// (i.e. without needing to invoke a lambda)
547+
res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1')
548+
res.setHeader('content-type', 'text/plain')
549+
res.statusCode = 404
550+
return {
551+
type: 'done',
552+
result: RenderResult.fromStatic('Server action not found.', 'text/plain'),
553+
}
554+
}
555+
536556
// If it can't be a Server Action, skip handling.
537557
// Note that this can be a false positive -- any multipart/urlencoded POST can get us here,
538558
// But won't know if it's an MPA action or not until we call `decodeAction` below.
539559
if (!isPossibleServerAction) {
540560
return null
541561
}
542562

563+
// We don't currently support URL encoded actions, so we bail out early.
564+
// Depending on if it's a fetch action or an MPA, we return a different response.
565+
if (isURLEncodedAction) {
566+
if (isFetchAction) {
567+
return {
568+
type: 'not-found',
569+
}
570+
} else {
571+
// This is an MPA action, so we return null
572+
return null
573+
}
574+
}
575+
576+
// If the app has no server actions at all, we can 404 early.
577+
if (!hasServerActions(serverActionsManifest)) {
578+
return handleUnrecognizedFetchAction(getActionNotFoundError(actionId))
579+
}
580+
543581
if (workStore.isStaticGeneration) {
544582
throw new Error(
545583
"Invariant: server actions can't be handled during static rendering"
@@ -660,24 +698,6 @@ export async function handleAction({
660698
}
661699
}
662700

663-
const handleUnrecognizedFetchAction = (err: unknown): HandleActionResult => {
664-
// If the deployment doesn't have skew protection, this is expected to occasionally happen,
665-
// so we use a warning instead of an error.
666-
console.warn(err)
667-
668-
// Return an empty response with a header that the client router will interpret.
669-
// We don't need to waste time encoding a flight response, and using a blank body + header
670-
// means that unrecognized actions can also be handled at the infra level
671-
// (i.e. without needing to invoke a lambda)
672-
res.setHeader(NEXT_ACTION_NOT_FOUND_HEADER, '1')
673-
res.setHeader('content-type', 'text/plain')
674-
res.statusCode = 404
675-
return {
676-
type: 'done',
677-
result: RenderResult.fromStatic('Server action not found.', 'text/plain'),
678-
}
679-
}
680-
681701
try {
682702
return await actionAsyncStorage.run(
683703
{ isAction: true },
@@ -713,6 +733,13 @@ export async function handleAction({
713733
const formData = await req.request.formData()
714734
if (isFetchAction) {
715735
// A fetch action with a multipart body.
736+
737+
try {
738+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
739+
} catch (err) {
740+
return handleUnrecognizedFetchAction(err)
741+
}
742+
716743
boundActionArguments = await decodeReply(
717744
formData,
718745
serverModuleMap,
@@ -721,6 +748,14 @@ export async function handleAction({
721748
} else {
722749
// Multipart POST, but not a fetch action.
723750
// Potentially an MPA action, we have to try decoding it to check.
751+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
752+
// TODO: This can be from skew or manipulated input. We should handle this case
753+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
754+
throw new Error(
755+
`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`
756+
)
757+
}
758+
724759
const action = await decodeAction(formData, serverModuleMap)
725760
if (typeof action === 'function') {
726761
// an MPA action.
@@ -786,20 +821,11 @@ export async function handleAction({
786821

787822
const actionData = Buffer.concat(chunks).toString('utf-8')
788823

789-
if (isURLEncodedAction) {
790-
const formData = formDataFromSearchQueryString(actionData)
791-
boundActionArguments = await decodeReply(
792-
formData,
793-
serverModuleMap,
794-
{ temporaryReferences }
795-
)
796-
} else {
797-
boundActionArguments = await decodeReply(
798-
actionData,
799-
serverModuleMap,
800-
{ temporaryReferences }
801-
)
802-
}
824+
boundActionArguments = await decodeReply(
825+
actionData,
826+
serverModuleMap,
827+
{ temporaryReferences }
828+
)
803829
}
804830
} else if (
805831
// The type check here ensures that `req` is correctly typed, and the
@@ -867,6 +893,12 @@ export async function handleAction({
867893
if (isFetchAction) {
868894
// A fetch action with a multipart body.
869895

896+
try {
897+
actionModId = getActionModIdOrError(actionId, serverModuleMap)
898+
} catch (err) {
899+
return handleUnrecognizedFetchAction(err)
900+
}
901+
870902
const busboy = (
871903
require('next/dist/compiled/busboy') as typeof import('next/dist/compiled/busboy')
872904
)({
@@ -914,7 +946,19 @@ export async function handleAction({
914946
}),
915947
duplex: 'half',
916948
})
949+
917950
const formData = await fakeRequest.formData()
951+
952+
if (areAllActionIdsValid(formData, serverModuleMap) === false) {
953+
// TODO: This can be from skew or manipulated input. We should handle this case
954+
// more gracefully but this preserves the prior behavior where decodeAction would throw instead.
955+
throw new Error(
956+
`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`
957+
)
958+
}
959+
960+
// TODO: Refactor so it is harder to accidentally decode an action before you have validated that the
961+
// action referred to is available.
918962
const action = await decodeAction(formData, serverModuleMap)
919963
if (typeof action === 'function') {
920964
// an MPA action.
@@ -974,20 +1018,11 @@ export async function handleAction({
9741018

9751019
const actionData = Buffer.concat(chunks).toString('utf-8')
9761020

977-
if (isURLEncodedAction) {
978-
const formData = formDataFromSearchQueryString(actionData)
979-
boundActionArguments = await decodeReply(
980-
formData,
981-
serverModuleMap,
982-
{ temporaryReferences }
983-
)
984-
} else {
985-
boundActionArguments = await decodeReply(
986-
actionData,
987-
serverModuleMap,
988-
{ temporaryReferences }
989-
)
990-
}
1021+
boundActionArguments = await decodeReply(
1022+
actionData,
1023+
serverModuleMap,
1024+
{ temporaryReferences }
1025+
)
9911026
}
9921027
} else {
9931028
throw new Error('Invariant: Unknown request type.')
@@ -1005,13 +1040,6 @@ export async function handleAction({
10051040
// / -> fire action -> POST / -> appRender1 -> modId for the action file
10061041
// /foo -> fire action -> POST /foo -> appRender2 -> modId for the action file
10071042

1008-
try {
1009-
actionModId =
1010-
actionModId ?? getActionModIdOrError(actionId, serverModuleMap)
1011-
} catch (err) {
1012-
return handleUnrecognizedFetchAction(err)
1013-
}
1014-
10151043
const actionMod = (await ComponentMod.__next_app__.require(
10161044
actionModId
10171045
)) as Record<string, (...args: unknown[]) => Promise<unknown>>
@@ -1198,10 +1226,121 @@ function getActionModIdOrError(
11981226
const actionModId = serverModuleMap[actionId]?.id
11991227

12001228
if (!actionModId) {
1201-
throw new Error(
1202-
`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`
1203-
)
1229+
throw getActionNotFoundError(actionId)
12041230
}
12051231

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

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)