Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subresource Integrity for App Directory #39729

Merged
merged 18 commits into from Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions errors/manifest.json
Expand Up @@ -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"
}
]
}
Expand Down
20 changes: 20 additions & 0 deletions 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-<script />'`: not allowed
- `'nonce-/>script<>'`: not allowed
- `'nonce-PHNjcmlwdCAvPg=='`: allowed
- `'nonce-Lz5zY3JpcHQ8Pg=='`: allowed

#### Possible Ways to Fix It

Replace the nonce value with a base64 encoded value.

### Useful Links

- [Content Security Policy Sources](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources)
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -78,6 +78,7 @@
"@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",
Expand Down
1 change: 1 addition & 0 deletions packages/next/build/entries.ts
Expand Up @@ -212,6 +212,7 @@ export function getEdgeServerEntry(opts: {
stringifiedConfig: JSON.stringify(opts.config),
pagesType: opts.pagesType,
appDirLoader: Buffer.from(opts.appDirLoader || '').toString('base64'),
sriEnabled: !opts.isDev && !!opts.config.experimental.sri?.algorithm,
}

return {
Expand Down
34 changes: 17 additions & 17 deletions packages/next/build/utils.ts
Expand Up @@ -1082,13 +1082,13 @@ export async function isPageStatic({
getStaticProps: mod.getStaticProps,
}
} else {
componentsResult = await loadComponents(
componentsResult = await loadComponents({
distDir,
page,
pathname: page,
serverless,
false,
false
)
hasServerComponents: false,
isAppPath: false,
})
}
const Comp = componentsResult.Component

Expand Down Expand Up @@ -1214,13 +1214,13 @@ export async function hasCustomGetInitialProps(
): Promise<boolean> {
require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig)

const components = await loadComponents(
const components = await loadComponents({
distDir,
page,
isLikeServerless,
false,
false
)
pathname: page,
serverless: isLikeServerless,
hasServerComponents: false,
isAppPath: false,
})
let mod = components.ComponentMod

if (checkingApp) {
Expand All @@ -1239,13 +1239,13 @@ export async function getNamedExports(
runtimeEnvConfig: any
): Promise<Array<string>> {
require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig)
const components = await loadComponents(
const components = await loadComponents({
distDir,
page,
isLikeServerless,
false,
false
)
pathname: page,
serverless: isLikeServerless,
hasServerComponents: false,
isAppPath: false,
})
let mod = components.ComponentMod

return Object.keys(mod)
Expand Down
11 changes: 10 additions & 1 deletion packages/next/build/webpack-config.ts
Expand Up @@ -61,6 +61,7 @@ import loadJsConfig from './load-jsconfig'
import { loadBindings } from './swc'
import { clientComponentRegex } from './webpack/loaders/utils'
import { AppBuildManifestPlugin } from './webpack/plugins/app-build-manifest-plugin'
import { SubresourceIntegrityPlugin } from './webpack/plugins/subresource-integrity-plugin'

const NEXT_PROJECT_ROOT = pathJoin(__dirname, '..', '..')
const NEXT_PROJECT_ROOT_DIST = pathJoin(NEXT_PROJECT_ROOT, 'dist')
Expand Down Expand Up @@ -1750,7 +1751,11 @@ export default async function getBaseWebpackConfig(
}),
// MiddlewarePlugin should be after DefinePlugin so NEXT_PUBLIC_*
// replacement is done before its process.env.* handling
isEdgeServer && new MiddlewarePlugin({ dev }),
isEdgeServer &&
new MiddlewarePlugin({
dev,
sriEnabled: !dev && !!config.experimental.sri?.algorithm,
}),
isClient &&
new BuildManifestPlugin({
buildId,
Expand Down Expand Up @@ -1800,6 +1805,10 @@ export default async function getBaseWebpackConfig(
dev,
isEdgeServer,
})),
!dev &&
isClient &&
!!config.experimental.sri?.algorithm &&
new SubresourceIntegrityPlugin(config.experimental.sri.algorithm),
!dev &&
isClient &&
new (require('./webpack/plugins/telemetry-plugin').TelemetryPlugin)(
Expand Down
Expand Up @@ -14,6 +14,7 @@ export type EdgeSSRLoaderQuery = {
stringifiedConfig: string
appDirLoader?: string
pagesType?: 'app' | 'pages' | 'root'
sriEnabled: boolean
}

export default async function edgeSSRLoader(this: any) {
Expand All @@ -30,6 +31,7 @@ export default async function edgeSSRLoader(this: any) {
stringifiedConfig,
appDirLoader: appDirLoaderBase64,
pagesType,
sriEnabled,
} = this.getOptions()

const appDirLoader = Buffer.from(
Expand Down Expand Up @@ -94,6 +96,9 @@ export default async function edgeSSRLoader(this: any) {
const reactLoadableManifest = self.__REACT_LOADABLE_MANIFEST
const rscManifest = self.__RSC_MANIFEST
const rscCssManifest = self.__RSC_CSS_MANIFEST
const subresourceIntegrityManifest = ${
sriEnabled ? 'self.__SUBRESOURCE_INTEGRITY_MANIFEST' : 'undefined'
}

const render = getRender({
dev: ${dev},
Expand All @@ -109,6 +114,7 @@ export default async function edgeSSRLoader(this: any) {
reactLoadableManifest,
serverComponentManifest: ${isServerComponent} ? rscManifest : null,
serverCSSManifest: ${isServerComponent} ? rscCssManifest : null,
subresourceIntegrityManifest,
config: ${stringifiedConfig},
buildId: ${JSON.stringify(buildId)},
})
Expand Down
Expand Up @@ -23,6 +23,7 @@ export function getRender({
appRenderToHTML,
pagesRenderToHTML,
serverComponentManifest,
subresourceIntegrityManifest,
serverCSSManifest,
config,
buildId,
Expand All @@ -38,6 +39,7 @@ export function getRender({
Document: DocumentType
buildManifest: BuildManifest
reactLoadableManifest: ReactLoadableManifest
subresourceIntegrityManifest?: Record<string, string>
serverComponentManifest: any
serverCSSManifest: any
appServerMod: any
Expand All @@ -48,6 +50,7 @@ export function getRender({
dev,
buildManifest,
reactLoadableManifest,
subresourceIntegrityManifest,
Document,
App: appMod.default as AppType,
}
Expand Down
29 changes: 22 additions & 7 deletions packages/next/build/webpack/plugins/middleware-plugin.ts
Expand Up @@ -17,6 +17,7 @@ import {
MIDDLEWARE_REACT_LOADABLE_MANIFEST,
NEXT_CLIENT_SSR_ENTRY_SUFFIX,
FLIGHT_SERVER_CSS_MANIFEST,
SUBRESOURCE_INTEGRITY_MANIFEST,
} from '../../../shared/lib/constants'

export interface EdgeFunctionDefinition {
Expand Down Expand Up @@ -74,12 +75,19 @@ function isUsingIndirectEvalAndUsedByExports(args: {
return false
}

function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
function getEntryFiles(
entryFiles: string[],
meta: EntryMetadata,
opts: { sriEnabled: boolean }
) {
const files: string[] = []
if (meta.edgeSSR) {
if (meta.edgeSSR.isServerComponent) {
files.push(`server/${FLIGHT_MANIFEST}.js`)
files.push(`server/${FLIGHT_SERVER_CSS_MANIFEST}.js`)
if (opts.sriEnabled) {
files.push(`server/${SUBRESOURCE_INTEGRITY_MANIFEST}.js`)
}
files.push(
...entryFiles
.filter(
Expand Down Expand Up @@ -112,8 +120,9 @@ function getEntryFiles(entryFiles: string[], meta: EntryMetadata) {
function getCreateAssets(params: {
compilation: webpack.Compilation
metadataByEntry: Map<string, EntryMetadata>
opts: { sriEnabled: boolean }
}) {
const { compilation, metadataByEntry } = params
const { compilation, metadataByEntry, opts } = params
return (assets: any) => {
const middlewareManifest: MiddlewareManifest = {
sortedMiddleware: [],
Expand Down Expand Up @@ -145,7 +154,7 @@ function getCreateAssets(params: {

const edgeFunctionDefinition: EdgeFunctionDefinition = {
env: Array.from(metadata.env),
files: getEntryFiles(entrypoint.getFiles(), metadata),
files: getEntryFiles(entrypoint.getFiles(), metadata, opts),
name: entrypoint.name,
page: page,
matchers,
Expand Down Expand Up @@ -708,13 +717,15 @@ function getExtractMetadata(params: {
}

export default class MiddlewarePlugin {
dev: boolean
private readonly dev: boolean
private readonly sriEnabled: boolean

constructor({ dev }: { dev: boolean }) {
constructor({ dev, sriEnabled }: { dev: boolean; sriEnabled: boolean }) {
this.dev = dev
this.sriEnabled = sriEnabled
}

apply(compiler: webpack.Compiler) {
public apply(compiler: webpack.Compiler) {
compiler.hooks.compilation.tap(NAME, (compilation, params) => {
const { hooks } = params.normalModuleFactory
/**
Expand Down Expand Up @@ -751,7 +762,11 @@ export default class MiddlewarePlugin {
name: 'NextJsMiddlewareManifest',
stage: (webpack as any).Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
getCreateAssets({ compilation, metadataByEntry })
getCreateAssets({
compilation,
metadataByEntry,
opts: { sriEnabled: this.sriEnabled },
})
)
})
}
Expand Down
@@ -0,0 +1,71 @@
import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
import crypto from 'crypto'
import { SUBRESOURCE_INTEGRITY_MANIFEST } from '../../../shared/lib/constants'

const PLUGIN_NAME = 'SubresourceIntegrityPlugin'

export type SubresourceIntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512'

export class SubresourceIntegrityPlugin {
constructor(private readonly algorithm: SubresourceIntegrityAlgorithm) {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this auto-assign? I couldn't find an example in the TypeScript docs for this 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems it does auto-assign with either readonly or private 👀 TIL

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a shorthand that I'd say you should only use if there's only a handful of positional arguments. Shouldn't use if you need to do any other work within the constructor.


public apply(compiler: webpack.Compiler) {
compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.afterOptimizeAssets.tap(
{
name: PLUGIN_NAME,
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
// Collect all the entrypoint files.
let files = new Set<string>()
for (const entrypoint of compilation.entrypoints.values()) {
const iterator = entrypoint?.getFiles()
if (!iterator) {
continue
}

for (const file of iterator) {
files.add(file)
}
}

// For each file, deduped, calculate the file hash.
const hashes: Record<string, string> = {}
for (const file of files.values()) {
// Get the buffer for the asset.
const asset = assets[file]
if (!asset) {
throw new Error(`could not get asset: ${file}`)
}

// Get the buffer for the asset.
const buffer = asset.buffer()

// Create the hash for the content.
const hash = crypto
.createHash(this.algorithm)
.update(buffer)
.digest()
.toString('base64')

hashes[file] = `${this.algorithm}-${hash}`
}

const json = JSON.stringify(hashes, null, 2)
const file = 'server/' + SUBRESOURCE_INTEGRITY_MANIFEST
assets[file + '.js'] = new sources.RawSource(
'self.__SUBRESOURCE_INTEGRITY_MANIFEST=' + json
// Work around webpack 4 type of RawSource being used
// TODO: use webpack 5 type by default
) as unknown as webpack.sources.RawSource
assets[file + '.json'] = new sources.RawSource(
json
// Work around webpack 4 type of RawSource being used
// TODO: use webpack 5 type by default
) as unknown as webpack.sources.RawSource
}
)
})
}
}
20 changes: 10 additions & 10 deletions packages/next/export/worker.ts
Expand Up @@ -290,13 +290,13 @@ export default async function exportPage({
getServerSideProps,
getStaticProps,
pageConfig,
} = await loadComponents(
} = await loadComponents({
distDir,
page,
pathname: page,
serverless,
!!serverComponents,
isAppPath
)
hasServerComponents: !!serverComponents,
isAppPath,
})
const ampState = {
ampFirst: pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
Expand Down Expand Up @@ -357,13 +357,13 @@ export default async function exportPage({
throw new Error(`Failed to render serverless page`)
}
} else {
const components = await loadComponents(
const components = await loadComponents({
distDir,
page,
pathname: page,
serverless,
!!serverComponents,
isAppPath
)
hasServerComponents: !!serverComponents,
isAppPath,
})
const ampState = {
ampFirst: components.pageConfig?.amp === true,
hasQuery: Boolean(query.amp),
Expand Down