零依赖 Node 静态服务器的路径防护:为什么 startsWith 不够
先说清楚环境和口径:正文里标了「实测」的请求与响应都在本机跑过,Node v24.9.0、Windows 10 x64,根目录取的是脚本自己所在目录的绝对路径(dirname(fileURLToPath(import.meta.url)));POSIX 的差异只在分隔符和大小写敏感性上,那一段是按 path.posix 推的,没有真机验证,会单独标出来。
给演示页配一个静态服务器,最省事的做法是用 Node 内置模块写三十行:解析 URL、拼出文件路径、读文件、按扩展名给 Content-Type。没有依赖,拷过去就能跑。
但「拼出文件路径」这一步,是这类服务器最容易被写错的一道边界。写错了,服务器就会把根目录之外的文件读出来——配置文件、密钥、别人的项目,只要进程有权限。
这篇从一个三十行的实现出发,逐层看它的防护做到了哪一步,以及 startsWith 为什么不够。带分隔符的那种写法在入门教程里已经常见,这篇想讲的是写完那一层之后还会漏什么。
先看这三十行
完整文件三十行,可以直接存成 server.mjs 跑起来(node server.mjs),根目录就是脚本自己所在的目录:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(fileURLToPath(import.meta.url));
const PORT = 8791;
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png' };
const server = createServer(async (req, res) => {
try {
let p = decodeURIComponent((req.url || '/').split('?')[0]);
if (p === '/' || p.endsWith('/')) p += 'index.html';
const full = normalize(join(ROOT, p));
if (!full.startsWith(ROOT)) {
res.writeHead(403).end('Forbidden');
return;
}
const body = await readFile(full);
res.writeHead(200, {
'Content-Type': TYPES[extname(full).toLowerCase()] || 'application/octet-stream',
'Cache-Control': 'no-store',
});
res.end(body);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('404 Not Found');
}
});
server.listen(PORT, '127.0.0.1');
逐项看,这段代码做对了几件事:
join(ROOT, p)而不是resolve(ROOT, p)。按文档语义,resolve遇到绝对路径参数会回头去找根、把先前的参数丢掉,join不会(这条我在 Windows 本机没法直接复现,留一点余地:Windows 下盘符相关的写法行为更绕)。用join的取舍是:从源头就不让绝对路径重置根目录,后面对..的处理只需要对付一种情况;normalize把..和.消掉,/a/../b变成/b;- 只监听
127.0.0.1,不对局域网开放; - 出错统一回 404,不把文件系统错误细节写进响应。够用,但代价是把「请求格式不对」和「文件不存在」混成一类,本文末尾「错误统一成 404」那一段有拆开的方法;
Cache-Control: no-store,开发时不会拿到旧文件。
问题出在拼出路径之后的前缀判断。
复现准备:目录结构与命令
后面的转录共用同一套目录:site 是服务器根目录,site-secret 是同前缀的兄弟目录,private 给联接用。
demo/
site/ server.mjs、index.html、data.txt、.env(内容 SECRET=1)
link(目录联接,指向 ..\private)
site-secret/ config.json(内容 token=abc123)
private/ data.txt(内容 outside-secret)
脚本放在 site 里,根目录才等于 site;放在 demo 下的话,根目录就成了 demo,后面的转录全都不成立。在 cmd 里建起来(先 cd 进 demo):
mkdir site site-secret private
echo x> site\index.html & echo x> site\data.txt & echo SECRET=1> site\.env
echo token=abc123> site-secret\config.json
echo outside-secret> private\data.txt
cd site & mklink /J link ..\private
mklink /J 建的是目录联接,不需要管理员权限,site\link 就是「根目录里那个指向外面的链接」。这条要在 cmd 里跑,git-bash 会把 /J 当路径处理;联接名要在 site 目录里用相对目标建,从 demo 写 site\link ..\private 会造出一个指向 demo 外面的悬空联接。dir /X site 能看到 .env 的短名 ENV~1,没有这一行说明该卷没启用 8.3 名生成,后面那条绕过在你机器上不成立。
转录里的 HTTP 200、HTTP 403 是整理过的两行:真实 curl 默认只打印响应体,状态码是加 -i 或 -w "%{http_code}" 取的,正文里为了短只留了状态行和响应体。
startsWith 挡不住同前缀的兄弟目录
startsWith(ROOT) 的意图是「最终路径必须在根目录里面」。看一个反例。
服务器脚本放在 site 目录,同级还有一个 site-secret 目录,里面放了一份配置。请求 /../site-secret/config.json,走下来是这样:
join(ROOT, p)直接得到site-secret\config.json的绝对路径(join自己就做了归一化,..在这里被解掉;后面那步normalize是重复的,实测两种写法的返回值一模一样);- 这个字符串以
ROOT开头,startsWith返回true; readFile顺利读出根目录外面的文件。
实测请求与响应(服务器按上面那段代码监听 127.0.0.1:8791,curl 加 --path-as-is 保留原始路径):
> curl --path-as-is http://127.0.0.1:8791/../site-secret/config.json
HTTP 200
token=abc123
把判断换成后面那种带分隔符的写法,同一个请求换成另一个结果:
> curl --path-as-is http://127.0.0.1:8791/../site-secret/config.json
HTTP 403
Forbidden
POSIX 上同理(这条只按 path.posix 的行为推的,本机是 Windows,没有真 POSIX 环境可跑):根目录 /srv/site,兄弟目录 /srv/site-backup,归一化后的字符串照样以 /srv/site 开头。
路径归一化这一步没有问题,.. 是按目录语义正确解掉的。出错的是把「字符串前缀」当成「目录前缀」这件事,目录边界必须带上分隔符。
浏览器在发请求之前会把 /../ 归一化掉,所以这个例子得用 curl --path-as-is 或者直接构造请求才复现得了。防护代码不能假设请求一定经过浏览器:命令行工具、代理、自己写的客户端,都可能原样发过来。
更稳的写法:带分隔符,或者用 relative
第一种写法是补上分隔符:
import { sep } from 'node:path';
if (full !== ROOT && !full.startsWith(ROOT + sep)) {
res.writeHead(403).end('Forbidden');
return;
}
full !== ROOT 是给「请求根目录本身」留的例外;实际代码里根目录请求已经被补成 index.html,这个例外通常用不上,但写上更清楚。如果 ROOT 本身可能以分隔符结尾(比如直接写死成 / 或 C:\),要先去掉尾分隔符再拼,否则会拼出双分隔符,把所有请求都拒掉。
第二种写法是用 path.relative 判断:
import { relative, isAbsolute, sep } from 'node:path';
const rel = relative(ROOT, full);
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
res.writeHead(403).end('Forbidden');
return;
}
relative 返回从根目录到目标路径的相对路径。三种情况要拒绝:
rel === '..':目标就是根目录的父目录;rel以..加分隔符开头:目标在根目录之外;isAbsolute(rel):在 Windows 上,目标可能在另一个盘,relative无法用相对路径表示,会直接返回绝对路径。
第三种情况容易被漏掉。C:\app\site 和 D:\secret 之间没有共同的盘符,relative 只能返回 D:\secret;如果不检查 isAbsolute,后面的读取就会跑到另一个盘。
符号链接:字符串检查管不到
前缀检查只发生在字符串上。如果根目录里有一个符号链接指向外面,字符串路径仍然在根目录内,检查通过,readFile 却会跟着链接读到外面。
这一层我在本机复现过。Windows 上用目录联接(mklink /J,不需要管理员权限)在根目录里放一个指向外面的链接,POSIX 上 ln -s 同理:
site\link -> ..\private (private 里有 data.txt)
请求 /link/data.txt
full = …\site\link\data.txt 前缀检查、relative 检查都通过
readFile 读出的是 private 里的内容
服务器按「带分隔符」那种写法跑起来,同一个请求:
> curl --path-as-is http://127.0.0.1:8791/link/data.txt
HTTP 200
outside-secret
字符串层面无懈可击,因为拼出来的路径确实一直在根目录里。
要挡住这一层,需要把检查从字符串层面推进到文件系统层面:
import { realpath } from 'node:fs/promises';
import { relative, isAbsolute, sep } from 'node:path';
const realRoot = await realpath(ROOT); // 模块顶层算一次,之后复用
// 请求处理函数内:
const realFull = await realpath(full);
const rel = relative(realRoot, realFull);
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
res.writeHead(403).end('Forbidden');
return;
}
realpath 会把路径上的符号链接全部解析成真实路径,再比较就可靠得多。同一台服务器换成上面这段检查,/link/data.txt 的返回变成 403,而 /index.html 仍然是 200,正常请求不受影响。这段用了模块顶层的 await:ESM 的顶层 await 从 Node 14.8 起支持(.mjs,或 package.json 里标了 type: module 的 .js);node: 前缀的内置模块名在 require() 里要到 14.18/16 才认,import 则更早。工程上直接按 Node 16 以上写最省心。代价是:
- 文件不存在时
realpath会抛错,需要当成 404 处理; - 每个请求多一次
realpath调用(根目录的可以在启动时算好缓存); - 检查和读取之间仍有极小的竞态窗口(检查完之后链接被换掉)。要彻底消除竞态,需要在打开文件之后基于同一个文件描述符做校验,这已经超出本文这三十行演示服务器的范围。
对本地演示,realpath 这一层通常可以不加;但只要根目录里可能出现别人放进去的链接,就必须加。
只监听 127.0.0.1 能挡什么
server.listen(PORT) 不传主机名时,Node 会去绑 ::(IPv6 可用时),双栈环境下等于对所有网卡开放,局域网里的其他机器可以直接连上。写成 server.listen(PORT, '127.0.0.1') 之后,只有本机能连。
这挡住了「同一个 Wi-Fi 下的人扫到端口」,但没有挡住:
- 本机的其他进程;
- 浏览器里的任意网页。网页可以向你本机的端口发请求,CORS 默认只阻止它读取响应,不阻止请求到达服务器;
- 如果服务器会处理写操作,跨站请求伪造就是另一个层面的问题。
对只读的静态服务器,「网页能发请求但读不到响应」听起来不严重,但不能靠它。下面是机制层面的推导,我没有实测(要一个可控的 DNS 和真实域名,本机演示条件不够):没有 Host 校验时,DNS rebinding 可以让页面把一个域名解析到 127.0.0.1,请求在浏览器看来就成了同源,CORS 不再拦截,响应内容能被读到;再叠上后面要讲的点文件问题,.env、.git/config 就可能被带走。近几版浏览器在收紧公网页面访问本机地址的行为,但把安全性寄托在浏览器版本上,不是服务器该做的取舍。
要防这一层,校验 Host 头只接受 localhost 和 127.0.0.1(带端口时允许端口)。点文件那一层有两条路:显式拒绝以 . 开头的路径段(它在 Windows 上不算终点,下文讲点文件的那一段有一个真实绕过),或者只放行已知扩展名。后者连短名一并挡住,两条选一即可。
另外,'127.0.0.1' 只覆盖 IPv4。显式用 http://[::1]:端口 访问会被拒;而浏览器和 curl 用 localhost 时通常先试 IPv6 再回退到 IPv4,实测仍能拿到 200,所以「localhost 打不开」这种直觉并不可靠。要么同时监听 IPv6 环回地址,要么在文档里明确只支持 127.0.0.1 这个写法。
响应头里加 X-Content-Type-Options: nosniff,防止浏览器把 .txt 当 HTML 渲染;.svg 允许直接打开时,里面是可以带脚本的。这两条与路径判断无关,但同一份代码里补上就行。
几个容易忽略的细节
解码顺序。 先 decodeURIComponent、再 normalize,%2e%2e%2f 才会变成 ../ 并被处理。反过来,先把 URL 按 / 切段、再逐段解码,/%2e%2e%2fsite-secret/config.json 解出来的段是 ../site-secret:.. 藏在段内部,「段等于 .. 就拒绝」这类规则看不见它。两行就能复现(本机跑过,规则命中 0 次):
'/%2e%2e%2fsite-secret/config.json'.split('/').map(decodeURIComponent)
// [ '', '../site-secret', 'config.json' ] —— 没有一段 === '..'
统一成「解码一次、归一化一次、检查一次」,最不容易出岔子。
解码失败。 decodeURIComponent('%') 会抛 URIError。上面的代码把它交给外层 catch,返回 404;进程不会崩,但这类请求格式问题应该单独捕获并返回 400,判断依据见本节末尾关于错误分类的那一段。
空字节。 %00 解码后是 \0,Node 的文件 API 直接拒绝这种参数:本机实测抛的是 ERR_INVALID_ARG_VALUE,不是 ENOENT,走外层 catch 之后仍然显示成 404。不要自己把空字节截断,截断反而可能把恶意路径变成合法路径。
点文件。 根目录里如果有 .git、.env,按现在的逻辑可以直接请求到。静态服务器要么显式拒绝以 . 开头的路径段,要么改成扩展名白名单(只放行 .html、.css、.js、.png 这些)。白名单更严格,但每加一种资源都要改代码。
拒绝「以点开头的段」在 Windows 上还不够。NTFS 会给短文件名生成 8.3 别名,.env 的别名是 ENV~1,不带点。实测(服务器加了「段以 . 开头就 403」这条规则):
> curl http://127.0.0.1:8791/.env (带点段拒绝的规则)
HTTP 403
> curl http://127.0.0.1:8791/ENV~1
HTTP 200
SECRET=1
同一个文件的两种写法,一个拦住一个放行。要真挡住,得比对打开之后的文件名,或者只放行已知扩展名,而不是看请求字符串长什么样。这条绕过有个前提:所在卷启用了 8.3 名生成。这个开关按卷设置,不同机器、不同盘的结果可能不一样(fsutil 8dot3name query <盘符> 要管理员权限才能查,我这边查不了),所以别信别人说的默认值,自己 dir /X 看一眼最快。本机演示目录所在卷能看到 .env 对应 ENV~1,上面的结果是在启用状态下测出来的;如果你那台没有短名,这条绕过自然不成立,但点文件本身还是得挡。
顺带两类 Windows 特有的坑。文件系统大小写不敏感,字符串检查却敏感,同一类请求换个大小写就换个结果:INDEX.HTML 和 index.html 是同一个文件,两个都返回 200;越界路径写成 /../SITE-SECRET/CONFIG.JSON 时前缀对不上,被 403 拦下,换成小写反而放行。拦住它的位置在大小写,不在目录边界,所以基于字符串的黑名单会随大小写漂移,别依赖它。另一类是设备名,CON、nul、aux 能通过全部字符串检查,但本机 Node 24 的 readFile 对它们统一报 ENOENT(errno -4058),内容读不到,错误分类仍然不准。
目录请求。 代码在路径以 / 结尾时补 index.html,没有目录列表功能。目录列表会把根目录里有什么全部暴露出来,省掉它是对的。要留意的是请求一个没有 index.html 的子目录会返回 404 而不是 403,语义不准,但不构成泄露。
错误统一成 404。 好处是不泄露「文件存在但没权限」这类信息;坏处是调试时看不到 EACCES、EMFILE 这类真正的故障。更稳妥的做法是:ENOENT、EISDIR、ENOTDIR 返回 404,其他错误记录到服务端日志并返回 500,响应体仍然不包含路径细节。
一条可以直接用的检查顺序
归一化之后,用「根目录 + 分隔符」做前缀判断,或者用 relative 判 .. 和绝对路径。根目录里可能有指向外部的链接(自己建的、解压带进来的都算),就在检查前加一次 realpath。监听 127.0.0.1 之外,Host 头要校验,点文件要挡。挡点文件有两条路:显式拒绝以点开头的路径段,或只放行已知扩展名,后者连短名一并挡住,选一条即可;只比对请求字符串是不够的。
startsWith 危险的地方在于它对绝大多数正常请求都返回正确结果,只在少数几种输入上错一次,而这几种输入平时的浏览器请求一个都不会触发。交付前主动构造四类:同前缀的兄弟目录(/../site-secret/config.json)、指向外面的链接(mklink /J 或 ln -s 造一个)、编码过的 ..(%2e%2e%2f);短名 ENV~1 这类别名要先确认所在卷启用了 8.3 名生成,dir /X 能看到短名才算必测项,看不到就不成立。兄弟目录那类用 curl --path-as-is 就能复现,联接那类得先把演示目录建好,编码 .. 那类是字符串层面的,写几行断言就能测。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)