项目说明
项目用标准库实现静态文件服务,重点演示如何安全地把 URL 映射到文件系统路径。
运行方法
执行 node server.js <root>,访问根目录下的文件。服务器根据扩展名设置 Content-Type,未找到返回 404。
验证方式
测试在临时目录启动服务,断言正常文件、目录、404 和 ../ 穿越请求。确认解析后的路径始终位于根目录内,符号链接和编码绕过被拒绝。生产应使用成熟静态服务器或 CDN,并配置缓存、压缩和访问日志。
验收清单
完成「带路径穿越防护的 Node.js 静态文件服务器」后,按节点或操作步骤逐项自查:准备可复现的本地环境,执行文档中的命令,记录输出与失败信息,并确认权限、空数据和失败恢复路径都有对应证据。
代码示例
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
};
export function createStaticServer(rootDir) {
const root = resolve(rootDir);
return createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
const filePath = resolve(join(root, pathname === '/' ? 'index.html' : pathname));
if (filePath !== root && !filePath.startsWith(root + sep)) {
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
res.end('forbidden');
return;
}
try {
const data = await readFile(filePath);
res.writeHead(200, { 'content-type': TYPES[extname(filePath)] ?? 'application/octet-stream' });
res.end(data);
} catch {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('not found');
}
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.argv[2] ?? '.';
createStaticServer(root).listen(3000, () => console.log('serving ' + root + ' on 3000'));
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStaticServer } from './server.mjs';
test('serves files and blocks traversal', async () => {
const root = mkdtempSync(join(tmpdir(), 'static-'));
writeFileSync(join(root, 'index.html'), '<h1>hello</h1>');
writeFileSync(join(root, 'style.css'), 'body{}');
const server = createStaticServer(root).listen(0);
const { port } = server.address();
try {
assert.equal((await fetch(`http://127.0.0.1:${port}/`)).status, 200);
assert.match((await fetch(`http://127.0.0.1:${port}/style.css`)).headers.get('content-type') ?? '', /text\/css/);
assert.equal((await fetch(`http://127.0.0.1:${port}/missing.txt`)).status, 404);
assert.equal((await fetch(`http://127.0.0.1:${port}/..%2f..%2fsecret.txt`)).status, 403);
} finally {
server.close();
rmSync(root, { recursive: true, force: true });
}
});