Skip to content

Commit

Permalink
feat: added sri support for app dir
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattjoh committed Aug 18, 2022
1 parent 7a93093 commit f4371d3
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 21 deletions.
5 changes: 5 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -60,6 +60,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 @@ -1799,6 +1800,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
@@ -0,0 +1,57 @@
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: any) {
compiler.hooks.make.tap(PLUGIN_NAME, (compilation: any) => {
compilation.hooks.afterOptimizeAssets.tap(
{
name: PLUGIN_NAME,
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets: any) => this.createAsset(assets, compilation)
)
})
}

private createAsset(assets: any, compilation: webpack.Compilation) {
// 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 content = assets[file].buffer()

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

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

const json = JSON.stringify(hashes, null, 2)
assets[SUBRESOURCE_INTEGRITY_MANIFEST] = new sources.RawSource(json)
}
}
59 changes: 50 additions & 9 deletions packages/next/server/app-render.tsx
Expand Up @@ -135,7 +135,8 @@ function useFlightResponse(
writable: WritableStream<Uint8Array>,
cachePrefix: string,
req: ReadableStream<Uint8Array>,
serverComponentManifest: any
serverComponentManifest: any,
nonce?: string
) {
const id = cachePrefix + ',' + (React as any).useId()
let entry = rscCache.get(id)
Expand All @@ -150,13 +151,17 @@ function useFlightResponse(
// We only attach CSS chunks to the inlined data.
const forwardReader = forwardStream.getReader()
const writer = writable.getWriter()
const startScriptTag = nonce
? `<script nonce=${JSON.stringify(nonce)}>`
: '<script>'

function process() {
forwardReader.read().then(({ done, value }) => {
if (!bootstrapped) {
bootstrapped = true
writer.write(
encodeText(
`<script>(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
`${startScriptTag}(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
JSON.stringify([0, id])
)})</script>`
)
Expand All @@ -167,7 +172,7 @@ function useFlightResponse(
writer.close()
} else {
const responsePartial = decodeText(value)
const scripts = `<script>(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
const scripts = `${startScriptTag}(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
JSON.stringify([1, id, responsePartial])
)})</script>`

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -240,7 +246,8 @@ function createServerComponentRenderer(
writable,
cachePrefix,
reqStream,
serverComponentManifest
serverComponentManifest,
nonce
)
return response.readRoot()
}
Expand Down Expand Up @@ -422,6 +429,7 @@ export async function renderToHTMLOrFlight(

const {
buildManifest,
subresourceIntegrityManifest,
serverComponentManifest,
serverCSSManifest,
supportsDynamicHTML,
Expand Down Expand Up @@ -987,6 +995,32 @@ 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 = csp
// Directives are split by ';'.
.split(';')
.map((directive) => directive.trim())
// The script directive is marked by the 'script-src' string.
.find((directive) => directive.startsWith('script-src'))
// Sources are split by ' '.
?.split(' ')
// Remove the 'strict-src' string.
.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)
}

/**
* A new React Component that renders the provided React Component
* using Flight which can then be rendered to HTML.
Expand Down Expand Up @@ -1015,7 +1049,8 @@ export async function renderToHTMLOrFlight(
transformStream: serverComponentsInlinedTransformStream,
serverComponentManifest,
serverContexts,
}
},
nonce
)

const flushEffectsCallbacks: Set<() => React.ReactNode> = new Set()
Expand Down Expand Up @@ -1068,10 +1103,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
),
},
})

Expand Down
7 changes: 5 additions & 2 deletions packages/next/server/base-server.ts
Expand Up @@ -249,7 +249,8 @@ export default abstract class Server<ServerOptions extends Options = Options> {
pathname: string,
query?: NextParsedUrlQuery,
params?: Params,
isAppDir?: boolean
isAppDir?: boolean,
sriEnabled?: boolean
): Promise<FindComponentsResult | null>
protected abstract getPagePath(pathname: string, locales?: string[]): string
protected abstract getFontManifest(): FontManifest | undefined
Expand Down Expand Up @@ -1810,8 +1811,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
page,
query,
ctx.renderOpts.params,
typeof appPath === 'string'
typeof appPath === 'string',
!!this.nextConfig.experimental.sri?.algorithm
)

if (result) {
try {
return await this.renderToResponseWithComponents(ctx, result)
Expand Down
9 changes: 9 additions & 0 deletions packages/next/server/config-schema.ts
Expand Up @@ -355,6 +355,15 @@ const configSchema = {
sharedPool: {
type: 'boolean',
},
sri: {
properties: {
algorithm: {
enum: ['sha256', 'sha384', 'sha512'] as any,
type: 'string',
},
},
type: 'object',
},
swcFileReading: {
type: 'boolean',
},
Expand Down
4 changes: 4 additions & 0 deletions packages/next/server/config-shared.ts
Expand Up @@ -8,6 +8,7 @@ import {
RemotePattern,
} from '../shared/lib/image-config'
import { ServerRuntime } from 'next/types'
import { SubresourceIntegrityAlgorithm } from '../build/webpack/plugins/subresource-integrity-plugin'

export type NextConfigComplete = Required<NextConfig> & {
images: Required<ImageConfigComplete>
Expand Down Expand Up @@ -145,6 +146,9 @@ export interface ExperimentalConfig {
}
swcPlugins?: Array<[string, Record<string, unknown>]>
largePageDataBytes?: number
sri?: {
algorithm?: SubresourceIntegrityAlgorithm
}
}

export type ExportPathMap = {
Expand Down
29 changes: 20 additions & 9 deletions packages/next/server/load-components.ts
Expand Up @@ -13,6 +13,7 @@ import {
BUILD_MANIFEST,
REACT_LOADABLE_MANIFEST,
FLIGHT_MANIFEST,
SUBRESOURCE_INTEGRITY_MANIFEST,
} from '../shared/lib/constants'
import { join } from 'path'
import { requirePage, getPagePath } from './require'
Expand All @@ -30,6 +31,7 @@ export type LoadComponentsReturnType = {
Component: NextComponentType
pageConfig: PageConfig
buildManifest: BuildManifest
subresourceIntegrityManifest?: Record<string, string>
reactLoadableManifest: ReactLoadableManifest
serverComponentManifest?: any
Document: DocumentType
Expand Down Expand Up @@ -64,7 +66,8 @@ export async function loadComponents(
pathname: string,
serverless: boolean,
hasServerComponents?: boolean,
appDirEnabled?: boolean
appDirEnabled?: boolean,
sriEnabled?: boolean
): Promise<LoadComponentsReturnType> {
if (serverless) {
const ComponentMod = await requirePage(pathname, distDir, serverless)
Expand Down Expand Up @@ -111,14 +114,21 @@ export async function loadComponents(
),
])

const [buildManifest, reactLoadableManifest, serverComponentManifest] =
await Promise.all([
require(join(distDir, BUILD_MANIFEST)),
require(join(distDir, REACT_LOADABLE_MANIFEST)),
hasServerComponents
? require(join(distDir, 'server', FLIGHT_MANIFEST + '.json'))
: null,
])
const [
buildManifest,
reactLoadableManifest,
serverComponentManifest,
subresourceIntegrityManifest,
] = await Promise.all([
require(join(distDir, BUILD_MANIFEST)),
require(join(distDir, REACT_LOADABLE_MANIFEST)),
hasServerComponents
? require(join(distDir, 'server', FLIGHT_MANIFEST + '.json'))
: null,
sriEnabled
? require(join(distDir, SUBRESOURCE_INTEGRITY_MANIFEST))
: undefined,
])

const Component = interopDefault(ComponentMod)
const Document = interopDefault(DocumentMod)
Expand All @@ -145,6 +155,7 @@ export async function loadComponents(
Document,
Component,
buildManifest,
subresourceIntegrityManifest,
reactLoadableManifest,
pageConfig: ComponentMod.config || {},
ComponentMod,
Expand Down
3 changes: 2 additions & 1 deletion packages/next/server/next-server.ts
Expand Up @@ -795,7 +795,8 @@ export default class NextNodeServer extends BaseServer {
pagePath!,
!this.renderOpts.dev && this._isLikeServerless,
this.renderOpts.serverComponents,
this.nextConfig.experimental.appDir
this.nextConfig.experimental.appDir,
!!this.nextConfig.experimental.sri?.algorithm
)

if (
Expand Down
2 changes: 2 additions & 0 deletions packages/next/shared/lib/constants.ts
Expand Up @@ -26,6 +26,8 @@ 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.json'
export const EXPORT_MARKER = 'export-marker.json'
export const EXPORT_DETAIL = 'export-detail.json'
export const PRERENDER_MANIFEST = 'prerender-manifest.json'
Expand Down

0 comments on commit f4371d3

Please sign in to comment.