Skip to content

Commit

Permalink
Subresource Integrity for App Directory (#39729)
Browse files Browse the repository at this point in the history
<!--
Thanks for opening a PR! Your contribution is much appreciated.
In order to make sure your PR is handled as smoothly as possible we
request that you follow the checklist sections below.
Choose the right checklist for the change that you're making:
-->

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 <steven@ceriously.com>
  • Loading branch information
wyattjoh and styfle committed Sep 8, 2022
1 parent 1858fa9 commit c6ef857
Show file tree
Hide file tree
Showing 27 changed files with 482 additions and 104 deletions.
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) {}

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

0 comments on commit c6ef857

Please sign in to comment.