/* Minimal static dev server for the component showcase site. Usage (from repo root): node site/dev-server.js Then open: http://127.0.0.1:3311/site/ Serves the repo root so /site, /frameworks and /.design_library are all reachable. */ 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); /* 默认端口 3311;AA_PORT 仅供验证脚本起独立实例(避免与正在运行的 3311 抢端口)。 非法值(非整数 / 越界 / 空串)一律回落到 3311,保持既有行为。 */ const PORT = (() => { const raw = process.env.AA_PORT; if (raw === undefined || raw === '') return 3311; const n = Number(raw); return Number.isInteger(n) && n > 0 && n < 65536 ? n : 3311; })(); const HOST = '127.0.0.1'; const ROOT = path.resolve(process.cwd()); const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.vue': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }; const SECURITY_HEADERS = { 'Content-Security-Policy': [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data:", "font-src 'self' data:", "connect-src 'self'", "frame-src 'self'", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", "form-action 'self'" ].join('; '), 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()' }; const SENSITIVE_SEGMENT = /^(?:\.git|\.env(?:\..*)?|backup(?:s)?|log(?:s)?)$/i; const SENSITIVE_FILE = /\.(?:bak|backup|log)$/i; function isSensitivePath(relativePath) { if (!relativePath || relativePath === '..' || path.isAbsolute(relativePath)) return true; const segments = relativePath.split(path.sep); return segments.some((segment) => SENSITIVE_SEGMENT.test(segment) || SENSITIVE_FILE.test(segment)); } const PUBLIC_ROOTS = new Set(['site', 'frameworks', '.design_library', 'tests', 'index.html', 'sitemap.xml']); const PUBLIC_SPEC_FILES = new Set(Array.from({ length: 10 }, (_, i) => `组件${i + 1}.txt`)); function isPublicPath(relativePath) { if (!relativePath || relativePath === '.' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return false; const segments = relativePath.split(path.sep); const top = segments[0]; if (top === 'index.html' || top === 'sitemap.xml') return segments.length === 1; if (top === 'site' || top === 'frameworks' || top === 'tests') return true; if (top === '.design_library') return true; // 兼容旧版文档站:仅允许仓库根的 10 份规范原文,其余根文件一律拒绝 return segments.length === 1 && PUBLIC_SPEC_FILES.has(top); } function writeResponse(res, statusCode, headers, body) { res.writeHead(statusCode, { ...SECURITY_HEADERS, ...headers }); res.end(body); } const server = http.createServer((req, res) => { const rawPath = req.url.split('?')[0]; // 原始路径含编码形态的 「.」「/」或 NUL 一律拒绝: // - %2e/%2f(含大小写变体与双重编码)解码后会把 /%2e%2e/x 归一化成合法根路径 // - %00 / %2500 解码后是 NUL,会让 fs.readFile 同步抛 TypeError 打挂进程 if (/%(?:2e|2f|252e|252f|00|2500)/i.test(rawPath)) { writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden'); return; } let urlPath; try { urlPath = decodeURIComponent(rawPath).replace(/\\/g, '/'); } catch (e) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); return; } // 解码后再校验一次 NUL(双保险):无论编码形态如何(含裸 NUL、平台解码差异), // 都不得把 NUL 带进后面的文件路径 if (urlPath.indexOf('\u0000') !== -1) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); return; } if (urlPath === '/' || urlPath === '') { writeResponse(res, 302, { Location: '/site/' }); return; } if (urlPath.endsWith('/')) urlPath += 'index.html'; // 根路径与常见入口自动跳转到站点,避免裸访问 404 if (urlPath === '/index.html') { writeResponse(res, 302, { Location: '/site/' }); return; } const filePath = path.resolve(ROOT, `.${urlPath}`); const relativePath = path.relative(ROOT, filePath); // 开发服务只开放站点运行所需的顶层目录;仓库元数据、配置、脚本一律拒绝 if (!isPublicPath(relativePath) || isSensitivePath(relativePath)) { writeResponse(res, 403, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Forbidden'); return; } // fs.readFile 会对含 NUL 的路径**同步**抛 ERR_INVALID_ARG_VALUE;异常若逃逸出请求 // 回调会打挂进程(单个畸形请求即可 DoS)。这里兜住同步抛错,转成 400 响应。 try { fs.readFile(filePath, (err, buf) => { if (err) { writeResponse(res, 404, { 'Content-Type': 'text/plain; charset=utf-8' }, '404 Not Found: ' + urlPath); return; } writeResponse(res, 200, { 'Content-Type': TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-cache' }, buf); }); } catch (e) { writeResponse(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad request'); } }); server.listen(PORT, HOST, () => { console.log('Aurora Admin showcase serving at http://' + HOST + ':' + PORT + '/site/'); }); server.on('error', (e) => { console.error('Server error:', e.message); process.exit(1); });