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:
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user