From c6ef857d5792bc201c85e73ebaea8f6b83cdf643 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 8 Sep 2022 16:17:15 -0600 Subject: [PATCH] Subresource Integrity for App Directory (#39729) This serves to add support for [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes for scripts added from the new app directory. This also has support for utilizing nonce values passed from request headers (expected to be generated per request in middleware) in the bootstrapping scripts via the `Content-Security-Policy` header as such: ``` Content-Security-Policy: script-src 'nonce-2726c7f26c' ``` Which results in the inline scripts having a new `nonce` attribute hash added. These features combined support for setting an aggressive Content Security Policy on scripts loaded. ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [x] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [x] Make sure the linting passes by running `pnpm lint` - [x] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples) Co-authored-by: Steven --- errors/manifest.json | 4 + errors/nonce-contained-invalid-characters.md | 20 +++ package.json | 1 + packages/next/build/entries.ts | 1 + packages/next/build/utils.ts | 34 ++--- packages/next/build/webpack-config.ts | 11 +- .../loaders/next-edge-ssr-loader/index.ts | 6 + .../loaders/next-edge-ssr-loader/render.ts | 3 + .../webpack/plugins/middleware-plugin.ts | 29 ++++- .../plugins/subresource-integrity-plugin.ts | 71 ++++++++++ packages/next/export/worker.ts | 20 +-- packages/next/server/app-render.tsx | 92 +++++++++++-- packages/next/server/base-server.ts | 3 +- packages/next/server/config-schema.ts | 9 ++ packages/next/server/config-shared.ts | 4 + .../next/server/dev/static-paths-worker.ts | 8 +- packages/next/server/htmlescape.ts | 2 +- packages/next/server/load-components.ts | 19 ++- packages/next/server/next-server.ts | 66 +++++----- packages/next/shared/lib/constants.ts | 1 + pnpm-lock.yaml | 2 + test/e2e/app-dir/app/next.config.js | 3 + test/e2e/app-dir/index.test.ts | 123 ++++++++++++++++++ test/integration/image-optimizer/test/util.ts | 17 ++- test/lib/next-test-utils.js | 21 ++- .../required-server-files-i18n.test.ts | 8 +- test/production/required-server-files.test.ts | 8 +- 27 files changed, 482 insertions(+), 104 deletions(-) create mode 100644 errors/nonce-contained-invalid-characters.md create mode 100644 packages/next/build/webpack/plugins/subresource-integrity-plugin.ts diff --git a/errors/manifest.json b/errors/manifest.json index 3f013867de559b8..42134b6ca26fcf3 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -729,6 +729,10 @@ { "title": "middleware-parse-user-agent", "path": "/errors/middleware-parse-user-agent.md" + }, + { + "title": "nonce-contained-invalid-characters", + "path": "/errors/nonce-contained-invalid-characters.md" } ] } diff --git a/errors/nonce-contained-invalid-characters.md b/errors/nonce-contained-invalid-characters.md new file mode 100644 index 000000000000000..3befd0651f9f02a --- /dev/null +++ b/errors/nonce-contained-invalid-characters.md @@ -0,0 +1,20 @@ +# nonce contained invalid characters + +#### Why This Error Occurred + +This happens when there is a request that contains a `Content-Security-Policy` +header that contains a `script-src` directive with a nonce value that contains +invalid characters (any one of `<>&` characters). For example: + +- `'nonce-` ) @@ -167,7 +172,7 @@ function useFlightResponse( writer.close() } else { const responsePartial = decodeText(value) - const scripts = `` @@ -205,7 +210,8 @@ function createServerComponentRenderer( serverContexts: Array< [ServerContextName: string, JSONValue: Object | number | string] > - } + }, + nonce?: string ) { // We need to expose the `__webpack_require__` API globally for // react-server-dom-webpack. This is a hack until we find a better way. @@ -240,7 +246,8 @@ function createServerComponentRenderer( writable, cachePrefix, reqStream, - serverComponentManifest + serverComponentManifest, + nonce ) return response.readRoot() } @@ -406,6 +413,56 @@ function getCssInlinedLinkTags( return [...chunks] } +function getScriptNonceFromHeader(cspHeaderValue: string): string | undefined { + const directives = cspHeaderValue + // Directives are split by ';'. + .split(';') + .map((directive) => directive.trim()) + + // First try to find the directive for the 'script-src', otherwise try to + // fallback to the 'default-src'. + const directive = + directives.find((dir) => dir.startsWith('script-src')) || + directives.find((dir) => dir.startsWith('default-src')) + + // If no directive could be found, then we're done. + if (!directive) { + return + } + + // Extract the nonce from the directive + const nonce = directive + .split(' ') + // Remove the 'strict-src'/'default-src' string, this can't be the nonce. + .slice(1) + .map((source) => source.trim()) + // Find the first source with the 'nonce-' prefix. + .find( + (source) => + source.startsWith("'nonce-") && + source.length > 8 && + source.endsWith("'") + ) + // Grab the nonce by trimming the 'nonce-' prefix. + ?.slice(7, -1) + + // If we could't find the nonce, then we're done. + if (!nonce) { + return + } + + // Don't accept the nonce value if it contains HTML escape characters. + // Technically, the spec requires a base64'd value, but this is just an + // extra layer. + if (ESCAPE_REGEX.test(nonce)) { + throw new Error( + 'Nonce value from Content-Security-Policy contained HTML escape characters.\nLearn more: https://nextjs.org/docs/messages/nonce-contained-invalid-characters' + ) + } + + return nonce +} + export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, @@ -426,6 +483,7 @@ export async function renderToHTMLOrFlight( const { buildManifest, + subresourceIntegrityManifest, serverComponentManifest, serverCSSManifest = {}, supportsDynamicHTML, @@ -999,6 +1057,13 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! + // Get the nonce from the incomming request if it has one. + const csp = req.headers['content-security-policy'] + let nonce: string | undefined + if (csp && typeof csp === 'string') { + nonce = getScriptNonceFromHeader(csp) + } + /** * A new React Component that renders the provided React Component * using Flight which can then be rendered to HTML. @@ -1027,7 +1092,8 @@ export async function renderToHTMLOrFlight( transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, serverContexts, - } + }, + nonce ) const flushEffectsCallbacks: Set<() => React.ReactNode> = new Set() @@ -1080,10 +1146,16 @@ export async function renderToHTMLOrFlight( ReactDOMServer, element: content, streamOptions: { + nonce, // Include hydration scripts in the HTML - bootstrapScripts: buildManifest.rootMainFiles.map( - (src) => `${renderOpts.assetPrefix || ''}/_next/` + src - ), + bootstrapScripts: subresourceIntegrityManifest + ? buildManifest.rootMainFiles.map((src) => ({ + src: `${renderOpts.assetPrefix || ''}/_next/` + src, + integrity: subresourceIntegrityManifest[src], + })) + : buildManifest.rootMainFiles.map( + (src) => `${renderOpts.assetPrefix || ''}/_next/` + src + ), }, }) diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index a518b36cfcfb789..411983b3ed9ff6c 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -245,6 +245,7 @@ export default abstract class Server { params: Params isAppPath: boolean appPaths?: string[] | null + sriEnabled?: boolean }): Promise protected abstract getFontManifest(): FontManifest | undefined protected abstract getPrerenderManifest(): PrerenderManifest @@ -1546,8 +1547,8 @@ export default abstract class Server { params: ctx.renderOpts.params || {}, isAppPath: Array.isArray(appPaths), appPaths, + sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, }) - if (result) { try { return await this.renderToResponseWithComponents(ctx, result) diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 1ed39ed3c9e69ab..984d0df6b77d75e 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -338,6 +338,15 @@ const configSchema = { sharedPool: { type: 'boolean', }, + sri: { + properties: { + algorithm: { + enum: ['sha256', 'sha384', 'sha512'] as any, + type: 'string', + }, + }, + type: 'object', + }, swcFileReading: { type: 'boolean', }, diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index b0bf627bfb9d1fb..e0e0ddcfe639300 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -7,6 +7,7 @@ import { imageConfigDefault, } from '../shared/lib/image-config' import { ServerRuntime } from 'next/types' +import { SubresourceIntegrityAlgorithm } from '../build/webpack/plugins/subresource-integrity-plugin' export type NextConfigComplete = Required & { images: Required @@ -146,6 +147,9 @@ export interface ExperimentalConfig { * [webpack/webpack#ModuleNotoundError.js#L13-L42](https://github.com/webpack/webpack/blob/2a0536cf510768111a3a6dceeb14cb79b9f59273/lib/ModuleNotFoundError.js#L13-L42) */ fallbackNodePolyfills?: false + sri?: { + algorithm?: SubresourceIntegrityAlgorithm + } } export type ExportPathMap = { diff --git a/packages/next/server/dev/static-paths-worker.ts b/packages/next/server/dev/static-paths-worker.ts index 185948d7f2d146b..e56d018b1f26720 100644 --- a/packages/next/server/dev/static-paths-worker.ts +++ b/packages/next/server/dev/static-paths-worker.ts @@ -38,13 +38,13 @@ export async function loadStaticPaths( require('../../shared/lib/runtime-config').setConfig(config) setHttpAgentOptions(httpAgentOptions) - const components = await loadComponents( + const components = await loadComponents({ distDir, pathname, serverless, - false, - false - ) + hasServerComponents: false, + isAppPath: false, + }) if (!components.getStaticPaths) { // we shouldn't get to this point since the worker should diff --git a/packages/next/server/htmlescape.ts b/packages/next/server/htmlescape.ts index 7bcda3c3570b775..fa06e75df98ac09 100644 --- a/packages/next/server/htmlescape.ts +++ b/packages/next/server/htmlescape.ts @@ -9,7 +9,7 @@ const ESCAPE_LOOKUP: { [match: string]: string } = { '\u2029': '\\u2029', } -const ESCAPE_REGEX = /[&><\u2028\u2029]/g +export const ESCAPE_REGEX = /[&><\u2028\u2029]/g export function htmlEscapeJsonString(str: string): string { return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]) diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 5cbf543ccc40b78..8f9f5431ff9e894 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -30,6 +30,7 @@ export type LoadComponentsReturnType = { Component: NextComponentType pageConfig: PageConfig buildManifest: BuildManifest + subresourceIntegrityManifest?: Record reactLoadableManifest: ReactLoadableManifest serverComponentManifest?: any Document: DocumentType @@ -59,13 +60,19 @@ export async function loadDefaultErrorComponents(distDir: string) { } } -export async function loadComponents( - distDir: string, - pathname: string, - serverless: boolean, - hasServerComponents: boolean, +export async function loadComponents({ + distDir, + pathname, + serverless, + hasServerComponents, + isAppPath, +}: { + distDir: string + pathname: string + serverless: boolean + hasServerComponents: boolean isAppPath: boolean -): Promise { +}): Promise { if (serverless) { const ComponentMod = await requirePage(pathname, distDir, serverless) if (typeof ComponentMod === 'string') { diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index f3cb0d914bbf816..be21b05c9e854b5 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -248,20 +248,20 @@ export default class NextNodeServer extends BaseServer { if (!options.dev) { // pre-warm _document and _app as these will be // needed for most requests - loadComponents( - this.distDir, - '/_document', - this._isLikeServerless, - false, - false - ).catch(() => {}) - loadComponents( - this.distDir, - '/_app', - this._isLikeServerless, - false, - false - ).catch(() => {}) + loadComponents({ + distDir: this.distDir, + pathname: '/_document', + serverless: this._isLikeServerless, + hasServerComponents: false, + isAppPath: false, + }).catch(() => {}) + loadComponents({ + distDir: this.distDir, + pathname: '/_app', + serverless: this._isLikeServerless, + hasServerComponents: false, + isAppPath: false, + }).catch(() => {}) } } @@ -932,39 +932,37 @@ export default class NextNodeServer extends BaseServer { params: Params | null isAppPath: boolean }): Promise { - let paths = [ + const paths: string[] = [pathname] + if (query.amp) { // try serving a static AMP version first - query.amp - ? (isAppPath - ? normalizeAppPath(pathname) - : normalizePagePath(pathname)) + '.amp' - : null, - pathname, - ].filter(Boolean) + paths.unshift( + (isAppPath ? normalizeAppPath(pathname) : normalizePagePath(pathname)) + + '.amp' + ) + } if (query.__nextLocale) { - paths = [ + paths.unshift( ...paths.map( (path) => `/${query.__nextLocale}${path === '/' ? '' : path}` - ), - ...paths, - ] + ) + ) } for (const pagePath of paths) { try { - const components = await loadComponents( - this.distDir, - pagePath!, - !this.renderOpts.dev && this._isLikeServerless, - !!this.renderOpts.serverComponents, - isAppPath - ) + const components = await loadComponents({ + distDir: this.distDir, + pathname: pagePath, + serverless: !this.renderOpts.dev && this._isLikeServerless, + hasServerComponents: !!this.renderOpts.serverComponents, + isAppPath, + }) if ( query.__nextLocale && typeof components.Component === 'string' && - !pagePath?.startsWith(`/${query.__nextLocale}`) + !pagePath.startsWith(`/${query.__nextLocale}`) ) { // if loading an static HTML file the locale is required // to be present since all HTML files are output under their locale diff --git a/packages/next/shared/lib/constants.ts b/packages/next/shared/lib/constants.ts index c314f8517b5b653..f17d05ba0f83924 100644 --- a/packages/next/shared/lib/constants.ts +++ b/packages/next/shared/lib/constants.ts @@ -26,6 +26,7 @@ export const APP_PATHS_MANIFEST = 'app-paths-manifest.json' export const APP_PATH_ROUTES_MANIFEST = 'app-path-routes-manifest.json' export const BUILD_MANIFEST = 'build-manifest.json' export const APP_BUILD_MANIFEST = 'app-build-manifest.json' +export const SUBRESOURCE_INTEGRITY_MANIFEST = 'subresource-integrity-manifest' export const EXPORT_MARKER = 'export-marker.json' export const EXPORT_DETAIL = 'export-detail.json' export const PRERENDER_MANIFEST = 'prerender-manifest.json' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94d2c42e8c5390e..5c59a89ecd0b798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,7 @@ importers: '@types/http-proxy': 1.17.3 '@types/jest': 24.0.13 '@types/node': 13.11.0 + '@types/node-fetch': 2.6.1 '@types/react': 16.9.17 '@types/react-dom': 16.9.4 '@types/relay-runtime': 13.0.0 @@ -194,6 +195,7 @@ importers: '@types/http-proxy': 1.17.3 '@types/jest': 24.0.13 '@types/node': 13.11.0 + '@types/node-fetch': 2.6.1 '@types/react': 16.9.17 '@types/react-dom': 16.9.4 '@types/relay-runtime': 13.0.0 diff --git a/test/e2e/app-dir/app/next.config.js b/test/e2e/app-dir/app/next.config.js index 087742808cea756..0e04741a08bf144 100644 --- a/test/e2e/app-dir/app/next.config.js +++ b/test/e2e/app-dir/app/next.config.js @@ -4,6 +4,9 @@ module.exports = { serverComponents: true, legacyBrowsers: false, browsersListForSwc: true, + sri: { + algorithm: 'sha256', + }, }, // assetPrefix: '/assets', rewrites: async () => { diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 6ff89b27588809b..e92332aa0c8ca25 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -1,4 +1,5 @@ import { createNext, FileRef } from 'e2e-utils' +import crypto from 'crypto' import { NextInstance } from 'test/lib/next-modes/base' import { check, fetchViaHTTP, renderViaHTTP, waitFor } from 'next-test-utils' import path from 'path' @@ -1194,6 +1195,128 @@ describe('app dir', () => { }) }) }) + ;(isDev ? describe.skip : describe)('Subresource Integrity', () => { + function fetchWithPolicy(policy: string | null) { + return fetchViaHTTP(next.url, '/dashboard', undefined, { + headers: policy + ? { + 'Content-Security-Policy': policy, + } + : {}, + }) + } + + async function renderWithPolicy(policy: string | null) { + const res = await fetchWithPolicy(policy) + + expect(res.ok).toBe(true) + + const html = await res.text() + + return cheerio.load(html) + } + + it('does not include nonce when not enabled', async () => { + const policies = [ + `script-src 'nonce-'`, // invalid nonce + 'style-src "nonce-cmFuZG9tCg=="', // no script or default src + '', // empty string + ] + + for (const policy of policies) { + const $ = await renderWithPolicy(policy) + + // Find all the script tags without src attributes and with nonce + // attributes. + const elements = $('script[nonce]:not([src])') + + // Expect there to be none. + expect(elements.length).toBe(0) + } + }) + + it('includes a nonce value with inline scripts when Content-Security-Policy header is defined', async () => { + // A random nonce value, base64 encoded. + const nonce = 'cmFuZG9tCg==' + + // Validate all the cases where we could parse the nonce. + const policies = [ + `script-src 'nonce-${nonce}'`, // base case + ` script-src 'nonce-${nonce}' `, // extra space added around sources and directive + `style-src 'self'; script-src 'nonce-${nonce}'`, // extra directives + `script-src 'self' 'nonce-${nonce}' 'nonce-othernonce'`, // extra nonces + `default-src 'nonce-othernonce'; script-src 'nonce-${nonce}';`, // script and then fallback case + `default-src 'nonce-${nonce}'`, // fallback case + ] + + for (const policy of policies) { + const $ = await renderWithPolicy(policy) + + // Find all the script tags without src attributes. + const elements = $('script:not([src])') + + // Expect there to be at least 1 script tag without a src attribute. + expect(elements.length).toBeGreaterThan(0) + + // Expect all inline scripts to have the nonce value. + elements.each((i, el) => { + expect(el.attribs['nonce']).toBe(nonce) + }) + } + }) + + it('includes an integrity attribute on scripts', async () => { + const html = await renderViaHTTP(next.url, '/dashboard') + + const $ = cheerio.load(html) + + // Find all the script tags with src attributes. + const elements = $('script[src]') + + // Expect there to be at least 1 script tag with a src attribute. + expect(elements.length).toBeGreaterThan(0) + + // Collect all the scripts with integrity hashes so we can verify them. + const files: [string, string][] = [] + + // For each of these attributes, ensure that there's an integrity + // attribute and starts with the correct integrity hash prefix. + elements.each((i, el) => { + const integrity = el.attribs['integrity'] + expect(integrity).toBeDefined() + expect(integrity).toStartWith('sha256-') + + const src = el.attribs['src'] + expect(src).toBeDefined() + + files.push([src, integrity]) + }) + + // For each script tag, ensure that the integrity attribute is the + // correct hash of the script tag. + for (const [src, integrity] of files) { + const res = await fetchViaHTTP(next.url, src) + expect(res.status).toBe(200) + const content = await res.text() + + const hash = crypto + .createHash('sha256') + .update(content) + .digest() + .toString('base64') + + expect(integrity).toEndWith(hash) + } + }) + + it('throws when escape characters are included in nonce', async () => { + const res = await fetchWithPolicy( + `script-src 'nonce-">"'` + ) + + expect(res.status).toBe(500) + }) + }) } describe('without assetPrefix', () => { diff --git a/test/integration/image-optimizer/test/util.ts b/test/integration/image-optimizer/test/util.ts index aa37a5aef7c78d4..0937682c742c607 100644 --- a/test/integration/image-optimizer/test/util.ts +++ b/test/integration/image-optimizer/test/util.ts @@ -15,6 +15,7 @@ import { waitFor, } from 'next-test-utils' import isAnimated from 'next/dist/compiled/is-animated' +import type { RequestInit } from 'node-fetch' const largeSize = 1080 // defaults defined in server/config.ts const sharpMissingText = `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended` @@ -115,10 +116,15 @@ async function expectAvifSmallerThanWebp(w, q, appPort) { expect(avif).toBeLessThanOrEqual(webp) } -async function fetchWithDuration(...args) { - console.warn('Fetching', args[1], args[2]) +async function fetchWithDuration( + appPort: string | number, + pathname: string, + query?: Record | string, + opts?: RequestInit +) { + console.warn('Fetching', pathname, query) const start = Date.now() - const res = await fetchViaHTTP(...args) + const res = await fetchViaHTTP(appPort, pathname, query, opts) const buffer = await res.buffer() const duration = Date.now() - start return { duration, buffer, res } @@ -140,7 +146,10 @@ export function runTests(ctx) { slowImageServer.port }/slow.png?delay=${1}&status=308` const query = { url, w: ctx.w, q: 39 } - const opts = { headers: { accept: 'image/webp' }, redirect: 'manual' } + const opts: RequestInit = { + headers: { accept: 'image/webp' }, + redirect: 'manual', + } const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts) expect(res.status).toBe(500) diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index d46441e156f38b4..6d07d22915e0827 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -83,6 +83,12 @@ export function initNextServerScript( }) } +/** + * @param {string | number} appPortOrUrl + * @param {string} [url] + * @param {string} [hostname] + * @returns + */ export function getFullUrl(appPortOrUrl, url, hostname) { let fullUrl = typeof appPortOrUrl === 'string' && appPortOrUrl.startsWith('http') @@ -110,11 +116,24 @@ export function renderViaAPI(app, pathname, query) { return app.renderToHTML({ url }, {}, pathname, query) } +/** + * @param {string | number} appPort + * @param {string} pathname + * @param {Record | string | undefined} [query] + * @param {import('node-fetch').RequestInit} [opts] + * @returns {Promise} + */ export function renderViaHTTP(appPort, pathname, query, opts) { return fetchViaHTTP(appPort, pathname, query, opts).then((res) => res.text()) } -/** @return {Promise} */ +/** + * @param {string | number} appPort + * @param {string} pathname + * @param {Record | string | undefined} [query] + * @param {import('node-fetch').RequestInit} [opts] + * @returns {Promise} + */ export function fetchViaHTTP(appPort, pathname, query, opts) { const url = `${pathname}${ typeof query === 'string' ? query : query ? `?${qs.stringify(query)}` : '' diff --git a/test/production/required-server-files-i18n.test.ts b/test/production/required-server-files-i18n.test.ts index 17c5df1c80dff2a..8d290d779df5390 100644 --- a/test/production/required-server-files-i18n.test.ts +++ b/test/production/required-server-files-i18n.test.ts @@ -171,7 +171,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -182,7 +182,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( @@ -194,7 +194,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -204,7 +204,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) await next.patchFile('standalone/data.txt', 'show') diff --git a/test/production/required-server-files.test.ts b/test/production/required-server-files.test.ts index 1d97b5bc5be3c35..de7d1bcc26c32de 100644 --- a/test/production/required-server-files.test.ts +++ b/test/production/required-server-files.test.ts @@ -422,7 +422,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -433,7 +433,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gsp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( @@ -445,7 +445,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'show') const res = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( @@ -455,7 +455,7 @@ describe('should set-up next', () => { await next.patchFile('standalone/data.txt', 'hide') const res2 = await fetchViaHTTP(appPort, '/gssp', undefined, { - redirect: 'manual ', + redirect: 'manual', }) await next.patchFile('standalone/data.txt', 'show')