Skip to content

Commit 1d6ffc3

Browse files
authored
Fix static/ file name encoding (#11373)
* Test `static/` file name encoding * Fix `static/` file name encoding
1 parent e83cd4a commit 1d6ffc3

File tree

10 files changed

+202
-18
lines changed

10 files changed

+202
-18
lines changed

packages/next/next-server/server/next-server.ts

+75-11
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs'
33
import { IncomingMessage, ServerResponse } from 'http'
44
import Proxy from 'http-proxy'
55
import nanoid from 'next/dist/compiled/nanoid/index.js'
6-
import { join, resolve, sep } from 'path'
6+
import { join, relative, resolve, sep } from 'path'
77
import { parse as parseQs, ParsedUrlQuery } from 'querystring'
88
import { format as formatUrl, parse as parseUrl, UrlWithParsedQuery } from 'url'
99
import { PrerenderManifest } from '../../build'
@@ -346,7 +346,11 @@ export default class Server {
346346
match: route('/static/:path*'),
347347
name: 'static catchall',
348348
fn: async (req, res, params, parsedUrl) => {
349-
const p = join(this.dir, 'static', ...(params.path || []))
349+
const p = join(
350+
this.dir,
351+
'static',
352+
...(params.path || []).map(encodeURIComponent)
353+
)
350354
await this.serveStatic(req, res, p, parsedUrl)
351355
return {
352356
finished: true,
@@ -705,14 +709,15 @@ export default class Server {
705709
match: route('/:path*'),
706710
name: 'public folder catchall',
707711
fn: async (req, res, params, parsedUrl) => {
708-
const path = `/${(params.path || []).join('/')}`
712+
const pathParts: string[] = params.path || []
713+
const path = `/${pathParts.join('/')}`
709714

710715
if (publicFiles.has(path)) {
711716
await this.serveStatic(
712717
req,
713718
res,
714719
// we need to re-encode it since send decodes it
715-
join(this.dir, 'public', encodeURIComponent(path)),
720+
join(this.publicDir, ...pathParts.map(encodeURIComponent)),
716721
parsedUrl
717722
)
718723
return {
@@ -1350,18 +1355,77 @@ export default class Server {
13501355
}
13511356
}
13521357

1353-
private isServeableUrl(path: string): boolean {
1354-
const resolved = resolve(path)
1358+
private _validFilesystemPathSet: Set<string> | null = null
1359+
private getFilesystemPaths(): Set<string> {
1360+
if (this._validFilesystemPathSet) {
1361+
return this._validFilesystemPathSet
1362+
}
1363+
1364+
const pathUserFilesStatic = join(this.dir, 'static')
1365+
let userFilesStatic: string[] = []
1366+
if (this.hasStaticDir && fs.existsSync(pathUserFilesStatic)) {
1367+
userFilesStatic = recursiveReadDirSync(pathUserFilesStatic).map(f =>
1368+
join('.', 'static', f)
1369+
)
1370+
}
1371+
1372+
let userFilesPublic: string[] = []
1373+
if (this.publicDir && fs.existsSync(this.publicDir)) {
1374+
userFilesPublic = recursiveReadDirSync(this.publicDir).map(f =>
1375+
join('.', 'public', f)
1376+
)
1377+
}
1378+
1379+
let nextFilesStatic: string[] = []
1380+
nextFilesStatic = recursiveReadDirSync(
1381+
join(this.distDir, 'static')
1382+
).map(f => join('.', relative(this.dir, this.distDir), 'static', f))
1383+
1384+
return (this._validFilesystemPathSet = new Set<string>([
1385+
...nextFilesStatic,
1386+
...userFilesPublic,
1387+
...userFilesStatic,
1388+
]))
1389+
}
1390+
1391+
protected isServeableUrl(untrustedFileUrl: string): boolean {
1392+
// This method mimics what the version of `send` we use does:
1393+
// 1. decodeURIComponent:
1394+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
1395+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L518-L522
1396+
// 2. resolve:
1397+
// https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L561
1398+
1399+
let decodedUntrustedFilePath: string
1400+
try {
1401+
// (1) Decode the URL so we have the proper file name
1402+
decodedUntrustedFilePath = decodeURIComponent(untrustedFileUrl)
1403+
} catch {
1404+
return false
1405+
}
1406+
1407+
// (2) Resolve "up paths" to determine real request
1408+
const untrustedFilePath = resolve(decodedUntrustedFilePath)
1409+
1410+
// don't allow null bytes anywhere in the file path
1411+
if (untrustedFilePath.indexOf('\0') !== -1) {
1412+
return false
1413+
}
1414+
1415+
// Check if .next/static, static and public are in the path.
1416+
// If not the path is not available.
13551417
if (
1356-
resolved.indexOf(join(this.distDir) + sep) !== 0 &&
1357-
resolved.indexOf(join(this.dir, 'static') + sep) !== 0 &&
1358-
resolved.indexOf(join(this.dir, 'public') + sep) !== 0
1418+
(untrustedFilePath.startsWith(join(this.distDir, 'static') + sep) ||
1419+
untrustedFilePath.startsWith(join(this.dir, 'static') + sep) ||
1420+
untrustedFilePath.startsWith(join(this.dir, 'public') + sep)) === false
13591421
) {
1360-
// Seems like the user is trying to traverse the filesystem.
13611422
return false
13621423
}
13631424

1364-
return true
1425+
// Check against the real filesystem paths
1426+
const filesystemUrls = this.getFilesystemPaths()
1427+
const resolved = relative(this.dir, untrustedFilePath)
1428+
return filesystemUrls.has(resolved)
13651429
}
13661430

13671431
protected readBuildId(): string {

packages/next/server/next-dev-server.ts

+51-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import crypto from 'crypto'
33
import findUp from 'find-up'
44
import fs from 'fs'
55
import { IncomingMessage, ServerResponse } from 'http'
6-
import { join, relative } from 'path'
6+
import Worker from 'jest-worker'
7+
import { join, relative, resolve, sep } from 'path'
78
import React from 'react'
89
import { UrlWithParsedQuery } from 'url'
910
import { promisify } from 'util'
@@ -30,7 +31,6 @@ import { Telemetry } from '../telemetry/storage'
3031
import ErrorDebug from './error-debug'
3132
import HotReloader from './hot-reloader'
3233
import { findPageFile } from './lib/find-page-file'
33-
import Worker from 'jest-worker'
3434

3535
if (typeof React.Suspense === 'undefined') {
3636
throw new Error(
@@ -279,7 +279,8 @@ export default class DevServer extends Server {
279279
parsedUrl: UrlWithParsedQuery
280280
) {
281281
const { pathname } = parsedUrl
282-
const path = `/${(params.path || []).join('/')}`
282+
const pathParts = params.path || []
283+
const path = `/${pathParts.join('/')}`
283284
// check for a public file, throwing error if there's a
284285
// conflicting page
285286
if (await this.hasPublicFile(path)) {
@@ -291,7 +292,7 @@ export default class DevServer extends Server {
291292
await this.renderError(err, req, res, pathname!, {})
292293
return true
293294
}
294-
await this.servePublic(req, res, path)
295+
await this.servePublic(req, res, pathParts)
295296
return true
296297
}
297298

@@ -536,9 +537,12 @@ export default class DevServer extends Server {
536537
res.setHeader('Cache-Control', 'no-store, must-revalidate')
537538
}
538539

539-
servePublic(req: IncomingMessage, res: ServerResponse, path: string) {
540-
const p = join(this.publicDir, encodeURIComponent(path))
541-
// we need to re-encode it since send decodes it
540+
private servePublic(
541+
req: IncomingMessage,
542+
res: ServerResponse,
543+
pathParts: string[]
544+
) {
545+
const p = join(this.publicDir, ...pathParts.map(encodeURIComponent))
542546
return this.serveStatic(req, res, p)
543547
}
544548

@@ -558,4 +562,44 @@ export default class DevServer extends Server {
558562
// Return the very first error we found.
559563
return errors[0]
560564
}
565+
566+
protected isServeableUrl(untrustedFileUrl: string): boolean {
567+
// This method mimics what the version of `send` we use does:
568+
// 1. decodeURIComponent:
569+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L989
570+
// https://github.com/pillarjs/send/blob/0.17.1/index.js#L518-L522
571+
// 2. resolve:
572+
// https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L561
573+
574+
let decodedUntrustedFilePath: string
575+
try {
576+
// (1) Decode the URL so we have the proper file name
577+
decodedUntrustedFilePath = decodeURIComponent(untrustedFileUrl)
578+
} catch {
579+
return false
580+
}
581+
582+
// (2) Resolve "up paths" to determine real request
583+
const untrustedFilePath = resolve(decodedUntrustedFilePath)
584+
585+
// don't allow null bytes anywhere in the file path
586+
if (untrustedFilePath.indexOf('\0') !== -1) {
587+
return false
588+
}
589+
590+
// During development mode, files can be added while the server is running.
591+
// Checks for .next/static, .next/server, static and public.
592+
// Note that in development .next/server is available for error reporting purposes.
593+
// see `packages/next/next-server/server/next-server.ts` for more details.
594+
if (
595+
untrustedFilePath.startsWith(join(this.distDir, 'static') + sep) ||
596+
untrustedFilePath.startsWith(join(this.distDir, 'server') + sep) ||
597+
untrustedFilePath.startsWith(join(this.dir, 'static') + sep) ||
598+
untrustedFilePath.startsWith(join(this.dir, 'public') + sep)
599+
) {
600+
return true
601+
}
602+
603+
return false
604+
}
561605
}

test/integration/basic/test/index.test.js

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import errorRecovery from './error-recovery'
99
import dynamic from './dynamic'
1010
import processEnv from './process-env'
1111
import publicFolder from './public-folder'
12+
import security from './security'
1213

1314
const context = {}
1415
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
@@ -35,4 +36,5 @@ describe('Basic Features', () => {
3536
errorRecovery(context, (p, q) => renderViaHTTP(context.appPort, p, q))
3637
processEnv(context)
3738
publicFolder(context)
39+
security(context)
3840
})
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* eslint-env jest */
2+
import { fetchViaHTTP } from 'next-test-utils'
3+
4+
module.exports = context => {
5+
describe('With Security Related Issues', () => {
6+
it('should not allow accessing files outside .next/static and .next/server directory', async () => {
7+
const pathsToCheck = [
8+
`/_next/static/../BUILD_ID`,
9+
`/_next/static/../routes-manifest.json`,
10+
]
11+
for (const path of pathsToCheck) {
12+
const res = await fetchViaHTTP(context.appPort, path)
13+
const text = await res.text()
14+
try {
15+
expect(res.status).toBe(404)
16+
expect(text).toMatch(/This page could not be found/)
17+
} catch (err) {
18+
throw new Error(`Path ${path} accessible from the browser`)
19+
}
20+
}
21+
})
22+
})
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world copy
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world %20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world +
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world

test/integration/dynamic-routing/test/index.test.js

+28
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,34 @@ function runTests(dev) {
443443
expect(res.status).toBe(200)
444444
})
445445

446+
it('should serve file with space from static folder', async () => {
447+
const res = await fetchViaHTTP(appPort, '/static/hello copy.txt')
448+
const text = (await res.text()).trim()
449+
expect(text).toBe('hello world copy')
450+
expect(res.status).toBe(200)
451+
})
452+
453+
it('should serve file with plus from static folder', async () => {
454+
const res = await fetchViaHTTP(appPort, '/static/hello+copy.txt')
455+
const text = (await res.text()).trim()
456+
expect(text).toBe('hello world +')
457+
expect(res.status).toBe(200)
458+
})
459+
460+
it('should serve file from static folder encoded', async () => {
461+
const res = await fetchViaHTTP(appPort, '/static/hello%20copy.txt')
462+
const text = (await res.text()).trim()
463+
expect(text).toBe('hello world copy')
464+
expect(res.status).toBe(200)
465+
})
466+
467+
it('should serve file with %20 from static folder', async () => {
468+
const res = await fetchViaHTTP(appPort, '/static/hello%2520copy.txt')
469+
const text = (await res.text()).trim()
470+
expect(text).toBe('hello world %20')
471+
expect(res.status).toBe(200)
472+
})
473+
446474
if (dev) {
447475
it('should work with HMR correctly', async () => {
448476
const browser = await webdriver(appPort, '/post-1/comments')

test/integration/production/test/security.js

+19
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ module.exports = context => {
5050
}
5151
})
5252

53+
it('should not allow accessing files outside .next/static directory', async () => {
54+
const pathsToCheck = [
55+
`/_next/static/../server/pages-manifest.json`,
56+
`/_next/static/../server/build-manifest.json`,
57+
`/_next/static/../BUILD_ID`,
58+
`/_next/static/../routes-manifest.json`,
59+
]
60+
for (const path of pathsToCheck) {
61+
const res = await fetchViaHTTP(context.appPort, path)
62+
const text = await res.text()
63+
try {
64+
expect(res.status).toBe(404)
65+
expect(text).toMatch(/This page could not be found/)
66+
} catch (err) {
67+
throw new Error(`Path ${path} accessible from the browser`)
68+
}
69+
}
70+
})
71+
5372
it("should not leak the user's home directory into the build", async () => {
5473
const buildId = readFileSync(join(__dirname, '../.next/BUILD_ID'), 'utf8')
5574

0 commit comments

Comments
 (0)