Skip to content

Commit 8ca32f1

Browse files
authored
Move static serving to next server (#33475)
Part of #31506 Decouple static serving logic from base-server, let them go to next-server only
1 parent 1d4f364 commit 8ca32f1

File tree

2 files changed

+162
-145
lines changed

2 files changed

+162
-145
lines changed

packages/next/server/base-server.ts

Lines changed: 6 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@ import type { PreviewData } from 'next/types'
2121
import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
2222
import type { BaseNextRequest, BaseNextResponse } from './base-http'
2323

24-
import { join, relative, resolve, sep } from 'path'
24+
import { join, resolve } from 'path'
2525
import { parse as parseQs, stringify as stringifyQs } from 'querystring'
2626
import { format as formatUrl, parse as parseUrl } from 'url'
2727
import { getRedirectStatus, modifyRouteRegex } from '../lib/load-custom-routes'
2828
import {
2929
CLIENT_PUBLIC_FILES_PATH,
30-
CLIENT_STATIC_FILES_PATH,
31-
CLIENT_STATIC_FILES_RUNTIME,
3230
PRERENDER_MANIFEST,
3331
ROUTES_MANIFEST,
3432
SERVERLESS_DIRECTORY,
@@ -190,6 +188,8 @@ export default abstract class Server {
190188
protected abstract getBuildId(): string
191189
protected abstract generatePublicRoutes(): Route[]
192190
protected abstract generateImageRoutes(): Route[]
191+
protected abstract generateStaticRotes(): Route[]
192+
protected abstract generateFsStaticRoutes(): Route[]
193193
protected abstract generateCatchAllMiddlewareRoute(): Route | undefined
194194
protected abstract getFilesystemPaths(): Set<string>
195195
protected abstract getMiddleware(): {
@@ -227,12 +227,6 @@ export default abstract class Server {
227227
}
228228
): Promise<void>
229229

230-
protected abstract sendStatic(
231-
req: BaseNextRequest,
232-
res: BaseNextResponse,
233-
path: string
234-
): Promise<void>
235-
236230
protected abstract runApi(
237231
req: BaseNextRequest,
238232
res: BaseNextResponse,
@@ -714,64 +708,10 @@ export default abstract class Server {
714708
} {
715709
const publicRoutes = this.generatePublicRoutes()
716710
const imageRoutes = this.generateImageRoutes()
717-
718-
const staticFilesRoute = this.hasStaticDir
719-
? [
720-
{
721-
// It's very important to keep this route's param optional.
722-
// (but it should support as many params as needed, separated by '/')
723-
// Otherwise this will lead to a pretty simple DOS attack.
724-
// See more: https://github.com/vercel/next.js/issues/2617
725-
match: route('/static/:path*'),
726-
name: 'static catchall',
727-
fn: async (req, res, params, parsedUrl) => {
728-
const p = join(this.dir, 'static', ...params.path)
729-
await this.serveStatic(req, res, p, parsedUrl)
730-
return {
731-
finished: true,
732-
}
733-
},
734-
} as Route,
735-
]
736-
: []
711+
const staticFilesRoutes = this.generateStaticRotes()
737712

738713
const fsRoutes: Route[] = [
739-
{
740-
match: route('/_next/static/:path*'),
741-
type: 'route',
742-
name: '_next/static catchall',
743-
fn: async (req, res, params, parsedUrl) => {
744-
// make sure to 404 for /_next/static itself
745-
if (!params.path) {
746-
await this.render404(req, res, parsedUrl)
747-
return {
748-
finished: true,
749-
}
750-
}
751-
752-
if (
753-
params.path[0] === CLIENT_STATIC_FILES_RUNTIME ||
754-
params.path[0] === 'chunks' ||
755-
params.path[0] === 'css' ||
756-
params.path[0] === 'image' ||
757-
params.path[0] === 'media' ||
758-
params.path[0] === this.buildId ||
759-
params.path[0] === 'pages' ||
760-
params.path[1] === 'pages'
761-
) {
762-
this.setImmutableAssetCacheControl(res)
763-
}
764-
const p = join(
765-
this.distDir,
766-
CLIENT_STATIC_FILES_PATH,
767-
...(params.path || [])
768-
)
769-
await this.serveStatic(req, res, p, parsedUrl)
770-
return {
771-
finished: true,
772-
}
773-
},
774-
},
714+
...this.generateFsStaticRoutes(),
775715
{
776716
match: route('/_next/data/:path*'),
777717
type: 'route',
@@ -860,7 +800,7 @@ export default abstract class Server {
860800
},
861801
},
862802
...publicRoutes,
863-
...staticFilesRoute,
803+
...staticFilesRoutes,
864804
]
865805

866806
const restrictedRedirectPaths = ['/_next'].map((p) =>
@@ -2063,78 +2003,6 @@ export default abstract class Server {
20632003
return this.renderError(null, req, res, pathname!, query, setHeaders)
20642004
}
20652005

2066-
public async serveStatic(
2067-
req: BaseNextRequest,
2068-
res: BaseNextResponse,
2069-
path: string,
2070-
parsedUrl?: UrlWithParsedQuery
2071-
): Promise<void> {
2072-
if (!this.isServeableUrl(path)) {
2073-
return this.render404(req, res, parsedUrl)
2074-
}
2075-
2076-
if (!(req.method === 'GET' || req.method === 'HEAD')) {
2077-
res.statusCode = 405
2078-
res.setHeader('Allow', ['GET', 'HEAD'])
2079-
return this.renderError(null, req, res, path)
2080-
}
2081-
2082-
try {
2083-
await this.sendStatic(req, res, path)
2084-
} catch (error) {
2085-
if (!isError(error)) throw error
2086-
const err = error as Error & { code?: string; statusCode?: number }
2087-
if (err.code === 'ENOENT' || err.statusCode === 404) {
2088-
this.render404(req, res, parsedUrl)
2089-
} else if (err.statusCode === 412) {
2090-
res.statusCode = 412
2091-
return this.renderError(err, req, res, path)
2092-
} else {
2093-
throw err
2094-
}
2095-
}
2096-
}
2097-
2098-
protected isServeableUrl(untrustedFileUrl: string): boolean {
2099-
// This method mimics what the version of `send` we use does:
2100-
// 1. decodeURIComponent:
2101-
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
2102-
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L518-L522
2103-
// 2. resolve:
2104-
// https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L561
2105-
2106-
let decodedUntrustedFilePath: string
2107-
try {
2108-
// (1) Decode the URL so we have the proper file name
2109-
decodedUntrustedFilePath = decodeURIComponent(untrustedFileUrl)
2110-
} catch {
2111-
return false
2112-
}
2113-
2114-
// (2) Resolve "up paths" to determine real request
2115-
const untrustedFilePath = resolve(decodedUntrustedFilePath)
2116-
2117-
// don't allow null bytes anywhere in the file path
2118-
if (untrustedFilePath.indexOf('\0') !== -1) {
2119-
return false
2120-
}
2121-
2122-
// Check if .next/static, static and public are in the path.
2123-
// If not the path is not available.
2124-
if (
2125-
(untrustedFilePath.startsWith(join(this.distDir, 'static') + sep) ||
2126-
untrustedFilePath.startsWith(join(this.dir, 'static') + sep) ||
2127-
untrustedFilePath.startsWith(join(this.dir, 'public') + sep)) === false
2128-
) {
2129-
return false
2130-
}
2131-
2132-
// Check against the real filesystem paths
2133-
const filesystemUrls = this.getFilesystemPaths()
2134-
const resolved = relative(this.dir, untrustedFilePath)
2135-
return filesystemUrls.has(resolved)
2136-
}
2137-
21382006
protected get _isLikeServerless(): boolean {
21392007
return isTargetLikeServerless(this.nextConfig.target)
21402008
}

packages/next/server/next-server.ts

Lines changed: 156 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ import type { FetchEventResult } from './web/types'
1212
import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url'
1313

1414
import fs from 'fs'
15-
import { join, relative } from 'path'
15+
import { join, relative, resolve, sep } from 'path'
1616
import { IncomingMessage, ServerResponse } from 'http'
1717

1818
import {
1919
PAGES_MANIFEST,
2020
BUILD_ID_FILE,
2121
SERVER_DIRECTORY,
2222
MIDDLEWARE_MANIFEST,
23+
CLIENT_STATIC_FILES_PATH,
24+
CLIENT_STATIC_FILES_RUNTIME,
2325
} from '../shared/lib/constants'
2426
import { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin'
2527
import { recursiveReadDirSync } from './lib/recursive-readdir-sync'
@@ -132,6 +134,69 @@ export default class NextNodeServer extends BaseServer {
132134
]
133135
}
134136

137+
protected generateStaticRotes(): Route[] {
138+
return this.hasStaticDir
139+
? [
140+
{
141+
// It's very important to keep this route's param optional.
142+
// (but it should support as many params as needed, separated by '/')
143+
// Otherwise this will lead to a pretty simple DOS attack.
144+
// See more: https://github.com/vercel/next.js/issues/2617
145+
match: route('/static/:path*'),
146+
name: 'static catchall',
147+
fn: async (req, res, params, parsedUrl) => {
148+
const p = join(this.dir, 'static', ...params.path)
149+
await this.serveStatic(req, res, p, parsedUrl)
150+
return {
151+
finished: true,
152+
}
153+
},
154+
} as Route,
155+
]
156+
: []
157+
}
158+
159+
protected generateFsStaticRoutes(): Route[] {
160+
return [
161+
{
162+
match: route('/_next/static/:path*'),
163+
type: 'route',
164+
name: '_next/static catchall',
165+
fn: async (req, res, params, parsedUrl) => {
166+
// make sure to 404 for /_next/static itself
167+
if (!params.path) {
168+
await this.render404(req, res, parsedUrl)
169+
return {
170+
finished: true,
171+
}
172+
}
173+
174+
if (
175+
params.path[0] === CLIENT_STATIC_FILES_RUNTIME ||
176+
params.path[0] === 'chunks' ||
177+
params.path[0] === 'css' ||
178+
params.path[0] === 'image' ||
179+
params.path[0] === 'media' ||
180+
params.path[0] === this.buildId ||
181+
params.path[0] === 'pages' ||
182+
params.path[1] === 'pages'
183+
) {
184+
this.setImmutableAssetCacheControl(res)
185+
}
186+
const p = join(
187+
this.distDir,
188+
CLIENT_STATIC_FILES_PATH,
189+
...(params.path || [])
190+
)
191+
await this.serveStatic(req, res, p, parsedUrl)
192+
return {
193+
finished: true,
194+
}
195+
},
196+
},
197+
]
198+
}
199+
135200
protected generatePublicRoutes(): Route[] {
136201
if (!fs.existsSync(this.publicDir)) return []
137202

@@ -575,12 +640,96 @@ export default class NextNodeServer extends BaseServer {
575640
path: string,
576641
parsedUrl?: UrlWithParsedQuery
577642
): Promise<void> {
578-
return super.serveStatic(
579-
this.normalizeReq(req),
580-
this.normalizeRes(res),
581-
path,
582-
parsedUrl
583-
)
643+
if (!this.isServeableUrl(path)) {
644+
return this.render404(req, res, parsedUrl)
645+
}
646+
647+
if (!(req.method === 'GET' || req.method === 'HEAD')) {
648+
res.statusCode = 405
649+
res.setHeader('Allow', ['GET', 'HEAD'])
650+
return this.renderError(null, req, res, path)
651+
}
652+
653+
try {
654+
await this.sendStatic(
655+
req as NodeNextRequest,
656+
res as NodeNextResponse,
657+
path
658+
)
659+
} catch (error) {
660+
if (!isError(error)) throw error
661+
const err = error as Error & { code?: string; statusCode?: number }
662+
if (err.code === 'ENOENT' || err.statusCode === 404) {
663+
this.render404(req, res, parsedUrl)
664+
} else if (err.statusCode === 412) {
665+
res.statusCode = 412
666+
return this.renderError(err, req, res, path)
667+
} else {
668+
throw err
669+
}
670+
}
671+
}
672+
673+
protected getStaticRoutes(): Route[] {
674+
return this.hasStaticDir
675+
? [
676+
{
677+
// It's very important to keep this route's param optional.
678+
// (but it should support as many params as needed, separated by '/')
679+
// Otherwise this will lead to a pretty simple DOS attack.
680+
// See more: https://github.com/vercel/next.js/issues/2617
681+
match: route('/static/:path*'),
682+
name: 'static catchall',
683+
fn: async (req, res, params, parsedUrl) => {
684+
const p = join(this.dir, 'static', ...params.path)
685+
await this.serveStatic(req, res, p, parsedUrl)
686+
return {
687+
finished: true,
688+
}
689+
},
690+
} as Route,
691+
]
692+
: []
693+
}
694+
695+
protected isServeableUrl(untrustedFileUrl: string): boolean {
696+
// This method mimics what the version of `send` we use does:
697+
// 1. decodeURIComponent:
698+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
699+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L518-L522
700+
// 2. resolve:
701+
// https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L561
702+
703+
let decodedUntrustedFilePath: string
704+
try {
705+
// (1) Decode the URL so we have the proper file name
706+
decodedUntrustedFilePath = decodeURIComponent(untrustedFileUrl)
707+
} catch {
708+
return false
709+
}
710+
711+
// (2) Resolve "up paths" to determine real request
712+
const untrustedFilePath = resolve(decodedUntrustedFilePath)
713+
714+
// don't allow null bytes anywhere in the file path
715+
if (untrustedFilePath.indexOf('\0') !== -1) {
716+
return false
717+
}
718+
719+
// Check if .next/static, static and public are in the path.
720+
// If not the path is not available.
721+
if (
722+
(untrustedFilePath.startsWith(join(this.distDir, 'static') + sep) ||
723+
untrustedFilePath.startsWith(join(this.dir, 'static') + sep) ||
724+
untrustedFilePath.startsWith(join(this.dir, 'public') + sep)) === false
725+
) {
726+
return false
727+
}
728+
729+
// Check against the real filesystem paths
730+
const filesystemUrls = this.getFilesystemPaths()
731+
const resolved = relative(this.dir, untrustedFilePath)
732+
return filesystemUrls.has(resolved)
584733
}
585734

586735
protected getMiddlewareInfo(params: {

0 commit comments

Comments
 (0)