Skip to content

Commit 9005604

Browse files
authored
fix: serve and list files with # or ? in their names (#616)
* fix: serve and list files with # or ? in their names * test: cover ? in file names and # or ? in directory names in listings
1 parent b38a463 commit 9005604

4 files changed

Lines changed: 104 additions & 3 deletions

File tree

‎index.js‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const endForwardSlashRegex = /\/$/u
2323
const asteriskRegex = /\*/gu
2424
const dotDotSegmentRegex = /(?:^|[\\/])\.\.(?:[\\/]|$)/u
2525
const leadingDotDotSegmentRegex = /^\/?\.\.(?:[\\/]|$)/u
26+
const encodedHashOrQuestionMarkRegex = /%(?:23|3f)/giu
2627

2728
const supportedEncodings = ['br', 'gzip', 'deflate']
2829
send.mime.default_type = 'application/octet-stream'
@@ -959,8 +960,8 @@ function getPathnameForSend (url, matchRoutePrefix) {
959960
}
960961

961962
try {
962-
const decodedUrlPathname = decodeURI(url.slice(0, pathnameEnd))
963-
const decodedPathname = decodeURI(pathname)
963+
const decodedUrlPathname = decodePathname(url.slice(0, pathnameEnd))
964+
const decodedPathname = decodePathname(pathname)
964965

965966
// Check the full raw URL path, because route params can consume a
966967
// dot-dot segment before the suffix is passed to @fastify/send.
@@ -982,6 +983,17 @@ function getPathnameForSend (url, matchRoutePrefix) {
982983
}
983984
}
984985

986+
/**
987+
* decodeURI() keeps `#` and `?` encoded, so a file with one of them in its
988+
* name could never be served. Decode those two first: neither is `%`, so this
989+
* cannot create a new escape sequence and the input is still decoded once.
990+
* @param {string} pathname
991+
* @returns {string}
992+
*/
993+
function decodePathname (pathname) {
994+
return decodeURI(pathname.replace(encodedHashOrQuestionMarkRegex, decodeURIComponent))
995+
}
996+
985997
/**
986998
* @param {string} url
987999
* @return {string}

‎lib/dirList.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
FST_STATIC_MULTIROOT_LIST_CONFLICT
1111
} = require('./errors.js')
1212
const fastqConcurrency = Math.max(1, os.cpus().length - 1)
13+
const URL_DELIMITERS_REG = /[#?]/g
1314

1415
function isPathInsideRoot (root, target) {
1516
const relativePath = path.relative(root, target)
@@ -164,7 +165,8 @@ const dirList = {
164165
route = path.posix.normalize(path.posix.join(route, '..'))
165166
}
166167
return {
167-
href: encodeURI(path.posix.join(prefix, route, entry.name)),
168+
// encodeURI leaves `#` and `?` as is, which would cut the href short
169+
href: encodeURI(path.posix.join(prefix, route, entry.name)).replace(URL_DELIMITERS_REG, encodeURIComponent),
168170
name: entry.name,
169171
stats: entry.stats,
170172
extendedInfo: entry.extendedInfo

‎test/dir-list.test.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
const fs = require('node:fs')
66
const http = require('node:http')
7+
const os = require('node:os')
78
const path = require('node:path')
89
const { test } = require('node:test')
910
const Fastify = require('fastify')
@@ -369,6 +370,63 @@ test('dir list href nested structure', async t => {
369370
})
370371
})
371372

373+
test('dir list html format - href encodes # and ? in file and directory names', async t => {
374+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fastify-static-dir-list-hash-'))
375+
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
376+
// `?` is not allowed in Windows file names
377+
const posix = process.platform !== 'win32'
378+
const files = { 'a#b.txt': '/public/a%23b.txt' }
379+
if (posix) {
380+
files['a?b.txt'] = '/public/a%3Fb.txt'
381+
}
382+
const dir = posix ? 'd#?x' : 'd#x'
383+
const dirHref = posix ? '/public/d%23%3Fx' : '/public/d%23x'
384+
for (const name of Object.keys(files)) {
385+
fs.writeFileSync(path.join(root, name), name)
386+
}
387+
fs.mkdirSync(path.join(root, dir))
388+
fs.writeFileSync(path.join(root, dir, 'c#d.txt'), 'nested')
389+
390+
const options = {
391+
root,
392+
prefix: '/public',
393+
index: false,
394+
list: {
395+
format: 'html',
396+
names: ['index'],
397+
render (dirs, files) {
398+
return JSON.stringify({
399+
dirs: dirs.map(entry => entry.href),
400+
files: files.map(entry => entry.href).sort()
401+
})
402+
}
403+
}
404+
}
405+
406+
await helper.arrange(t, options, async (url) => {
407+
const response = await fetch(url + '/public/index')
408+
t.assert.deepStrictEqual(response.status, 200)
409+
const listing = JSON.parse(await response.text())
410+
t.assert.deepStrictEqual(listing.dirs, [dirHref])
411+
t.assert.deepStrictEqual(listing.files, Object.values(files).sort())
412+
413+
for (const [name, href] of Object.entries(files)) {
414+
const file = await fetch(url + href)
415+
t.assert.deepStrictEqual(file.status, 200)
416+
t.assert.deepStrictEqual(await file.text(), name)
417+
}
418+
419+
const nested = await fetch(url + dirHref + '/index')
420+
t.assert.deepStrictEqual(nested.status, 200)
421+
const nestedListing = JSON.parse(await nested.text())
422+
t.assert.deepStrictEqual(nestedListing.files, [dirHref + '/c%23d.txt'])
423+
424+
const file = await fetch(url + nestedListing.files[0])
425+
t.assert.deepStrictEqual(file.status, 200)
426+
t.assert.deepStrictEqual(await file.text(), 'nested')
427+
})
428+
})
429+
372430
test('dir list html format - stats', async t => {
373431
t.plan(6)
374432

‎test/static.test.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5353,3 +5353,32 @@ test('register with wildcard false and globIgnore', async t => {
53535353
await response.text()
53545354
})
53555355
})
5356+
5357+
test('serves files with # or ? in their names', async (t) => {
5358+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fastify-static-reserved-'))
5359+
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
5360+
const files = { 'a#b.txt': '/a%23b.txt', '100%#.txt': '/100%25%23.txt' }
5361+
// `?` is not allowed in Windows file names
5362+
if (process.platform !== 'win32') {
5363+
files['a?b.txt'] = '/a%3Fb.txt'
5364+
}
5365+
for (const name of Object.keys(files)) {
5366+
fs.writeFileSync(path.join(root, name), name)
5367+
}
5368+
fs.writeFileSync(path.join(root, 'a%23b.txt'), 'literal')
5369+
5370+
const fastify = Fastify()
5371+
t.after(() => fastify.close())
5372+
fastify.register(fastifyStatic, { root, prefix: '/public' })
5373+
5374+
for (const [name, urlPath] of Object.entries(files)) {
5375+
const response = await fastify.inject('/public' + urlPath)
5376+
t.assert.deepStrictEqual(response.statusCode, 200)
5377+
t.assert.deepStrictEqual(response.body, name)
5378+
}
5379+
5380+
// `%25` still decodes to a literal `%`, and only once
5381+
const literal = await fastify.inject('/public/a%2523b.txt')
5382+
t.assert.deepStrictEqual(literal.statusCode, 200)
5383+
t.assert.deepStrictEqual(literal.body, 'literal')
5384+
})

0 commit comments

Comments
 (0)