test(evidence): 阶段4端到端预览验收

Plan 4 Task 8:真实 hesupipe 全链(register 副本复用 → import → publish
→ build → preview)临时库端到端验收,断言 21/21 PASS + 浏览器时刻 6 截图
无破版无横向溢出。新增 scripts/e2e-real.mjs(chain/serve 两子命令)与
docs/evidence/phase4-preview.md + phase4-assertions.json + screenshots/。

验收内修补(预览服务器,均有回归测试锁定):
- Location 头 ASCII 化:非 ASCII 码位按 UTF-8 百分号编码——原样 setHeader
  触发 ERR_INVALID_CHAR 崩 500 或 latin1 mojibake(1746 redirect 全量
  校验显影 2+3 例);manifest 原串保持权威,测试 2b 锁定。
- CSP 放行内联样式:Astro 5 scoped 样式内联 <style> 被 default-src 'self'
  拦截(浏览器时刻显影,HTTP 级不可见);测试 6 断言同步。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 14:33:55 +08:00
parent 66ca95d37f
commit c64f6ac48a
11 changed files with 1536 additions and 10 deletions
+30 -6
View File
@@ -5,14 +5,18 @@
// 以 `pathname+search` 全串匹配且优先于 ignore 行;ignore 行剥 query 按 path 匹配。
// html/asset → target_file 文件服务(**流式**:createReadStream 直pipe,Content-Length
// 取自先行 stat——大文件不驻留内存;HEAD 只发头零读盘;manifest content_type 优先,
// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location;
// 缺席走扩展名 MIME 表,未知扩展 application/octet-stream);redirect → status+Location
// (**头域 ASCII 化**:manifest location 含非 ASCII 码位(如全角括号/间隔号)时按
// UTF-8 百分号编码后发头——RFC 9110 头域是 ASCII,原样 setHeader 会 ERR_INVALID_CHAR
// 崩成 500 或被对端按 latin1 误解码;manifest 原串保持权威,线上形态是派生面);
// gone → 410;dynamic/unresolved → 404(静态产物无对应文件);无匹配 → 404(dist/
// 404.html 自定义页)。
// - 安全:回环绑定(host 仅 127.0.0.1/localhost,库级即拒);路径穿越预拒(反斜杠/
// 编码形态/.. 段)+ target_file resolve 后必须在 dist 内(lexical + realpath 双检);
// 目录即 404(禁目录列表);仅 GET/HEAD(其余 405)。
// - 头纪律:全响应 X-Robots-Tag: noindex, nofollow;text/html 响应附基础 CSP
// (default-src 'self')。请求日志一行(method/path/status/耗时)到 stdout
// (default-src 'self'; style-src 'self' 'unsafe-inline'——Astro 内联 scoped 样式放行,
// 见常量处注记)。请求日志一行(method/path/status/耗时)到 stdout
// (quiet: true 静默)。
//
// 本文件刻意保持 .mjs 纯 JavaScript(不进 TS 链);TS 消费方(scripts/sf.ts 与集成
@@ -49,7 +53,11 @@ const MIME_BY_EXT = new Map([
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost'])
const X_ROBOTS_TAG = 'noindex, nofollow'
const CSP = "default-src 'self'"
// CSP(Task 8 浏览器验收实证修补):Astro 5 静态产物把 scoped 样式内联为 <style> 元素
// (无外部样式表)——default-src 'self' 会拦掉全部内联样式,页面在浏览器里裸奔(HTTP
// 级测试看不到,浏览器时刻才显影)。style-src 放开 'unsafe-inline'(仅样式面;脚本/
// 连接/图片等仍走 default-src 'self')。
const CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'"
/**
* 请求路径安全预检(**先于 URL 解析**——new URL 会把 '/../' 规范化到 '/',裸点段
@@ -76,11 +84,26 @@ function contentTypeForExt(file) {
return MIME_BY_EXT.get(extname(file).toLowerCase()) ?? 'application/octet-stream'
}
/** HTML 文本转义(redirect 迁移页内嵌 location 用——Location 头本身保持原样) */
/** HTML 文本转义(redirect 迁移页内嵌 location 用——页面体内嵌原文,头走 asciiLocation) */
function escapeHtml(s) {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
/**
* Location 头 ASCII 化(Task 8 全量 1746 redirect 校验实证修补):非 ASCII 码位按
* UTF-8 百分号编码(encodeURIComponent 单码位即该码位的 UTF-8 八位组,hex 大写——
* 与 WP/nginx 的百分号编码形态一致);可打印 ASCII(含既有 %xx 序列的字面字符)原样。
* 控制字符/CR/LF 同路编码——头注入面封死(值内裸 % 后非 hex 也只是保留字面,不解码)。
*/
function asciiLocation(value) {
let out = ''
for (const ch of value) {
const code = ch.codePointAt(0)
out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch)
}
return out
}
/** 头纪律唯一出口:全响应 noindex;text/html 附 CSP;扩展头(Location/Allow 等) */
function applyHeaders(res, status, contentType, extraHeaders = undefined) {
res.statusCode = status
@@ -243,10 +266,11 @@ export function createPreviewServer({ distDir, routeManifest, host = '127.0.0.1'
if (row.kind === 'redirect') {
const location = typeof row.location === 'string' ? row.location : '/'
const status = row.status >= 300 && row.status <= 399 ? row.status : 301
// Location 头原样;HTML 迁移页内嵌时转义(</&/" ——防 manifest location 注入标记)
// Location 头 ASCII 化后发(manifest 原串含全角括号/间隔号等非 ASCII 时原样发头会
// ERR_INVALID_CHAR 崩 500——Task 8 全量 redirect 校验实证);HTML 迁移页内嵌原文转义
const safeLocation = escapeHtml(location)
send(res, status, 'text/html; charset=utf-8', htmlDocument('Moved', `本页已迁移:<a href="${safeLocation}">${safeLocation}</a>`), isHead, {
Location: location,
Location: asciiLocation(location),
})
return status
}