Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/next/src/compiled/http-proxy/index.js

Large diffs are not rendered by default.

25 changes: 16 additions & 9 deletions packages/next/src/server/app-render/action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,14 @@ export async function handleAction({
workStore.fetchCache = 'default-no-store'

const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
? new URL(originHeader).host
const originHost =
typeof originHeader === 'string'
? // 'null' is a valid origin e.g. from privacy-sensitive contexts like sandboxed iframes.
// However, these contexts can still send along credentials like cookies,
// so we need to check if they're allowed cross-origin requests.
originHeader === 'null'
? 'null'
: new URL(originHeader).host
: undefined
const host = parseHostHeader(req.headers)

Expand All @@ -631,15 +636,17 @@ export async function handleAction({
}
// This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
// ensure that the request is coming from the same host.
if (!originDomain) {
// This might be an old browser that doesn't send `host` header. We ignore
// this case.
if (!originHost) {
// This is a handcrafted request without an origin or a request from an unsafe browser.
// We'll let this through but log a warning.
// We can't guard against unsafe browsers and handcrafted requests can't contain
// user credentials that haven't been shared willingly.
warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originDomain !== host.value) {
} else if (!host || originHost !== host.value) {
// If the customer sets a list of allowed origins, we'll allow the request.
// These are considered safe but might be different from forwarded host set
// by the infra (i.e. reverse proxies).
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
if (isCsrfOriginAllowed(originHost, serverActions?.allowedOrigins)) {
// Ignore it
} else {
if (host) {
Expand All @@ -650,7 +657,7 @@ export async function handleAction({
}\` header with value \`${limitUntrustedHeaderValueForLogs(
host.value
)}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs(
originDomain
originHost
)}\` from a forwarded Server Actions request. Aborting the action.`
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import { LRUCache } from '../lru-cache'

let memoryCache: LRUCache<CacheHandlerValue> | undefined

function getBufferSize(buffer: Buffer | undefined) {
return buffer?.length || 0
}

function getSegmentDataSize(segmentData: Map<string, Buffer> | undefined) {
if (!segmentData) {
return 0
}

let size = 0

for (const [segmentPath, buffer] of segmentData) {
size += segmentPath.length + getBufferSize(buffer)
}

return size
}

export function getMemoryCache(maxMemoryCacheSize: number) {
if (!memoryCache) {
memoryCache = new LRUCache(maxMemoryCacheSize, function length({ value }) {
Expand All @@ -19,14 +37,17 @@ export function getMemoryCache(maxMemoryCacheSize: number) {
return value.body.length
}
// rough estimate of size of cache value
return (
value.html.length +
(JSON.stringify(
value.kind === CachedRouteKind.APP_PAGE
? value.rscData
: value.pageData
)?.length || 0)
)
if (value.kind === CachedRouteKind.APP_PAGE) {
return Math.max(
1,
value.html.length +
getBufferSize(value.rscData) +
(value.postponed?.length || 0) +
getSegmentDataSize(value.segmentData)
)
}

return value.html.length + (JSON.stringify(value.pageData)?.length || 0)
})
}

Expand Down
50 changes: 47 additions & 3 deletions patches/http-proxy@1.18.1.patch
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
diff --git a/lib/http-proxy/common.js b/lib/http-proxy/common.js
index 6513e81d80d5250ea249ea833f819ece67897c7e..486d4c896d65a3bb7cf63307af68facb3ddb886b 100644
index 6513e81d80d5250ea249ea833f819ece67897c7e..09143dd1fe4e67885f40ea916a6ea1ef3e3afa19 100644
--- a/lib/http-proxy/common.js
+++ b/lib/http-proxy/common.js
@@ -1,6 +1,5 @@
@@ -1,9 +1,9 @@
var common = exports,
url = require('url'),
- extend = require('util')._extend,
required = require('requires-port');

var upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i,
@@ -40,10 +39,10 @@ common.setupOutgoing = function(outgoing, options, req, forward) {
+ hopByHopTransferEncodingHeader = /(^|,)\s*transfer-encoding\s*($|,)/i,
isSSL = /^https|wss/;

/**
@@ -40,10 +40,10 @@ common.setupOutgoing = function(outgoing, options, req, forward) {
);

outgoing.method = options.method || req.method;
Expand All @@ -22,6 +26,30 @@ index 6513e81d80d5250ea249ea833f819ece67897c7e..486d4c896d65a3bb7cf63307af68facb
}

if (options.auth) {
@@ -61,13 +61,22 @@ common.setupOutgoing = function(outgoing, options, req, forward) {

outgoing.agent = options.agent || false;
outgoing.localAddress = options.localAddress;
+ outgoing.headers = outgoing.headers || {};
+ var hasTransferEncodingHeader = Object.keys(outgoing.headers).some(function (header) {
+ return header.toLowerCase() === 'transfer-encoding'
+ && typeof outgoing.headers[header] !== 'undefined';
+ });
+
+ if (hasTransferEncodingHeader
+ || (typeof outgoing.headers.connection === 'string'
+ && hopByHopTransferEncodingHeader.test(outgoing.headers.connection))
+ ) { outgoing.headers.connection = 'close'; }

//
// Remark: If we are false and not upgrading, set the connection: close. This is the right thing to do
// as node core doesn't handle this COMPLETELY properly yet.
//
if (!outgoing.agent) {
- outgoing.headers = outgoing.headers || {};
if (typeof outgoing.headers.connection !== 'string'
|| !upgradeHeader.test(outgoing.headers.connection)
) { outgoing.headers.connection = 'close'; }
diff --git a/lib/http-proxy/index.js b/lib/http-proxy/index.js
index 977a4b3622b9eaac27689f06347ea4c5173a96cd..88b2d0fcfa03c3aafa47c7e6d38e64412c45a7cc 100644
--- a/lib/http-proxy/index.js
Expand All @@ -44,3 +72,19 @@ index 977a4b3622b9eaac27689f06347ea4c5173a96cd..88b2d0fcfa03c3aafa47c7e6d38e6441

cntr--;
}
diff --git a/lib/http-proxy/passes/web-incoming.js b/lib/http-proxy/passes/web-incoming.js
index 7ae735514190eea569c605fff7d27c045fe8d601..c7c25e7228b21c76b3c7115af82ddcbf13a8e3ec 100644
--- a/lib/http-proxy/passes/web-incoming.js
+++ b/lib/http-proxy/passes/web-incoming.js
@@ -33,9 +33,9 @@ module.exports = {

deleteLength: function deleteLength(req, res, options) {
if((req.method === 'DELETE' || req.method === 'OPTIONS')
- && !req.headers['content-length']) {
+ && typeof req.headers['content-length'] === 'undefined'
+ && typeof req.headers['transfer-encoding'] === 'undefined') {
req.headers['content-length'] = '0';
- delete req.headers['transfer-encoding'];
}
},

10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,4 @@ describe('app-dir action allowed origins', () => {
return await browser.elementByCss('#res').text()
}, 'hi')
})

it('should not crash for requests from privacy sensitive contexts', async function () {
const res = await next.fetch('/', {
method: 'POST',
headers: {
Origin: 'null',
'Content-type': 'application/x-www-form-urlencoded',
'Sec-Fetch-Site': 'same-origin',
},
})

expect({ status: res.status }).toEqual({ status: 200 })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { join } from 'path'

describe('app-dir action allowed from opaque origins', () => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'opaque-origin'),
skipDeployment: true,
env: {
NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '1',
},
})

if (skipped) {
return
}

it('should succeed on submission', async function () {
const browser = await next.browser('/sandboxed')

await browser.elementByCss('input[type="submit"]').click()

await retry(async () => {
expect(await browser.elementByCss('output').text()).toEqual(
'Action Invoked'
)
})
})
})

describe('app-dir action disallowed from opaque origins', () => {
const { isNextDev, next, skipped } = nextTestSetup({
files: join(__dirname, 'opaque-origin'),
skipDeployment: true,
env: {
NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '',
},
})

if (skipped) {
return
}

it('should fail on submission', async function () {
const browser = await next.browser('/sandboxed')
const beforeSubmissionLogOffset = (await browser.log()).length

await browser.elementByCss('input[type="submit"]').click()

await retry(async () => {
const logs = await browser.log()
const newLogs = logs.slice(beforeSubmissionLogOffset)
expect(newLogs).toEqual(
expect.arrayContaining([
{
source: 'error',
message:
'Failed to load resource: the server responded with a status of 500 (Internal Server Error)',
},
])
)
})
if (isNextDev) {
// page is borked at this point. Nothing interesting to assert on.
} else {
expect(await browser.elementByCss('body').text()).toEqual(
'Internal Server Error'
)
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use server'

import { cookies } from 'next/headers'

export async function log() {
console.log('action invoked')
const cookieStore = await cookies()
cookieStore.set('log-action-invoked', '1')
return 'hi'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Suspense } from 'react'

export default function RootLayout({ children }) {
return (
// Needs to be above html since we can't allow scripts in sandbox
<Suspense fallback={<div>Loading...</div>}>
<html>
<head />
<body>
<ul>
{/* These need to be MPAs so that the appropriate headers are applied */}
<li>
<a href="/sandboxed">Sandboxed Page</a>
</li>
</ul>
{children}
</body>
</html>
</Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cookies } from 'next/headers'
import { log } from '../action'

export default async function Page() {
const cookieStore = await cookies()
const cookie = cookieStore.get('log-action-invoked')
const hasLogged = cookie?.value === '1'
return (
<form action={log}>
<input type="submit" />
<output>{hasLogged ? 'Action Invoked' : 'Action Not Invoked'}</output>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const allowOpaqueOrigin = process.env.NEXT_TEST_ALLOW_OPAQUE_ORIGIN === '1'

/** @type {import('next').NextConfig} */
module.exports = {
productionBrowserSourceMaps: true,
logging: {
fetches: {},
},
headers() {
return [
{
source: '/sandboxed',
headers: [
{
key: 'Content-Security-Policy',
value: 'sandbox allow-forms',
},
],
},
]
},
experimental: {
serverActions: {
allowedOrigins: allowOpaqueOrigin ? ['null'] : [],
},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ReactNode } from 'react'

export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Loading
Loading