fix(app-dev): align embedCsp contract with platform boolean flags
- embedCsp is connectSrc + wasmUnsafeEval + blob (all default off), not per-directive lists - cross-origin script/worker loading is unsupported; fetch + Blob is the supported path - check-app.sh/selftest validate flags and platform capability boundaries
This commit is contained in:
1 parent
3022d74a92
commit
3010fca54c
8 files changed
+115
-111
No files matched your search
@@ -113,7 +113,7 @@ aigrego-release publish --manifest app.manifest.json [--image <ref>] [--store] [
|
||||
```
|
||||
|
||||
- **manifest 进平台是双通道、同一份文件**:CLI `publish --manifest`(上)或平台侧 console「应用中心」按向导导入——同一条 submit 管道(同一套校验、治理字段变更同样 202 进审核队列)。不管走哪条,仓库里的 `app.manifest.json` 都必须最新且合法;走手动通道时把这份文件交付给平台管理员。
|
||||
- **大产物分流(先量再发)**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限(已压缩 wasm/`.data` 几乎压不动)→ 核心放对象存储 `apps/<key>/<version>/`、dist 只留壳、manifest 按指令声明 `embedCsp`(connect/script/worker/img/media,只能是 `blob:` 或 `scheme://host[:port]`)。平台基线固定 `script-src 'self' 'wasm-unsafe-eval'`(wasm 编译必需);外链 stylesheet 仍未放开。桶必须配 CORS(含 `Allow-Headers: Range`)。细节见 `references/contract.md §7.1`。
|
||||
- **大产物分流(先量再发)**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限(已压缩 wasm/`.data` 几乎压不动)→ 核心放对象存储 `apps/<key>/<version>/`、dist 只留壳、manifest 声明 `embedCsp`(`connectSrc` + `wasmUnsafeEval` + `blob`,三项默认关闭、都进审核)。核心 JS 用 `fetch` 下载后 `URL.createObjectURL` 成 Blob 执行;跨域直载 script/Worker 不支持。桶必须配 CORS(含 `Allow-Headers: Range`)。细节见 `references/contract.md §7.1`。
|
||||
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`/`mounts`/`embedCsp`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。
|
||||
- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。
|
||||
- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。
|
||||
|
||||
@@ -115,10 +115,10 @@
|
||||
| I1 | dist 打包成 tgz 后 ≤64 MiB(平台请求体硬上限);未压缩体积超限但实测 tgz 达标 → 仅提示 | die(tgz 超限)/ warn(压缩率依赖) | 脚本实算 `tar czf -` |
|
||||
| I2 | dist 内无单文件 >8 MiB 的已压缩资源(`.wasm`/`.data`/`.br`/`.gz`/`.zip`/`.7z`/`.mp4`);有则必须外置 | warn,tgz 超限时 die | 文件大小扫描 |
|
||||
| I3 | 需要外置时 manifest 必须声明 `embedCsp.connectSrc`(非空) | die | 脚本联动 I1/I2 |
|
||||
| I4 | `embedCsp` 结构合法:仅 `type=micro`;connect/script/worker/img/media 五条指令各自 ≤8 条、合计 ≤20 条;每项只能是 `blob:` 或 `scheme://host[:port]`(可 `*.`),无路径/查询/裸 `*`/分号/空白/重复;`'self'`/`'unsafe-eval'` 等关键字拒绝(wasm 基线平台已固定) | die | 正则 + 去重 |
|
||||
| I4 | `embedCsp` 结构合法:仅 `type=micro`;`connectSrc` 每项 `scheme://host[:port]`(可 `*.`,≤8 条,无路径/查询/裸 `*`/分号/空白/重复);`wasmUnsafeEval`/`blob` 必须是布尔 | die | 正则 + 类型 |
|
||||
| I5 | 声明与用点对得上:源码有外部 origin 的 `fetch`/`instantiateStreaming` 但 `connectSrc` 未声明 → warn;声明了但源码找不到使用点 → warn(死配置) | warn | grep 源码与构建产物 |
|
||||
| I6 | 跨域加载方式已按指令声明:外链 `<script src>` / 动态 `import()` / `importScripts()` 的 origin 必须在 `scriptSrc`;`new Worker('https://…')` 的 origin 必须在 `workerSrc`;`new Worker(blob:)` + `createObjectURL` 需要 `workerSrc: ["blob:"]`;未声明 → die(PROD CSP 会拦) | die | grep |
|
||||
| I6 | 加载方式符合平台能力:跨域 `<script src>` / `new Worker('https://…')` / `importScripts('https://…')` 平台**不支持**(未放开 script-src/worker-src origin)→ die,改为 `fetch` + `URL.createObjectURL`;出现 `new Worker(blob:)` 或 `createObjectURL` 必须 `blob: true`;用到 `WebAssembly`/`.wasm` 必须 `wasmUnsafeEval: true` | die | grep |
|
||||
| I7 | 外置路径版本化(`apps/<key>/<version>/`)+ immutable 缓存;回滚时旧版本仍在桶里 | warn | 读 CI/构建脚本 |
|
||||
| I8 | 桶 CORS 已配:GET/HEAD/OPTIONS、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;匿名读自检通过 | die(上线前,人工确认) | 桶控制台 + CI 自检日志 |
|
||||
| I9 | `embedCsp` 变更知悉审核语义(202 进队列,批准前不生效) | log | 阅读/口头确认 |
|
||||
| I10 | wasm 编译不再另需 `'unsafe-eval'`:平台基线已含 `script-src 'self' 'wasm-unsafe-eval'`;清单里再写关键字会被校验拒绝 | die `[S]` | I4 已覆盖 |
|
||||
| I9 | `embedCsp` 三项变更知悉审核语义(202 进队列,批准前不生效) | log | 阅读/口头确认 |
|
||||
| I10 | wasm 必须显式开 `wasmUnsafeEval`(默认关闭);不得用 `'unsafe-eval'` 兜底(平台不支持且有安全审计) | die `[S]` | I4/I6 已覆盖 |
|
||||
@@ -167,19 +167,20 @@ logger.info('request', { method, path, status, ms }); // 每请求一行
|
||||
- **分流**:dist tgz ≤64 MiB → 照旧 `publish --dist`;超限 → 核心放对象存储,dist 只留壳。
|
||||
- **对象存储布局**:`<bucket>/apps/<key>/<version>/...`(版本化 + `Cache-Control: public, max-age=31536000, immutable`,旧版本保留供回滚)。
|
||||
- **桶 CORS(必需)**:`Allow-Origin` 平台域(或 `*`)、`Allow-Methods: GET,HEAD,OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`。带 `Range` 的 `fetch` 会触发预检,只开公共读不够。
|
||||
- **基线(无需声明)**:所有 micro 产物页固定带 `script-src 'self' 'wasm-unsafe-eval'`——WebAssembly 编译必需,不解锁 JS `eval`。
|
||||
- **manifest 声明**(仅 micro,治理字段 → 变更 202 进审核):按指令声明额外来源,只允许 `blob:` 与 `scheme://host[:port]`(可 `*.` 前缀),每指令 ≤8 条、整份 ≤20 条、无路径/查询/裸 `*`/分号/空白;`'self'`/`'unsafe-eval'` 等关键字拒绝。
|
||||
- **默认全关**:`embedCsp` 三项互相独立、默认关闭;未声明时响应头与旧版本逐字节一致(没有 `script-src`,wasm 编译会被拦)。
|
||||
- **manifest 声明**(仅 micro,三项都是治理字段 → 变更 202 进审核):
|
||||
```json
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"],
|
||||
"imgSrc": ["blob:"],
|
||||
"mediaSrc": ["blob:"]
|
||||
"wasmUnsafeEval": true,
|
||||
"blob": true
|
||||
}
|
||||
```
|
||||
- **指令覆盖**:`connect-src` = fetch/XHR/WebSocket/wasm 流式加载;`worker-src` = `new Worker`;`script-src` = 动态脚本与 Worker 内 `importScripts`;`img-src`/`media-src` = blob 图片/音视频。未声明的指令回落 `default-src 'self'`。
|
||||
- **生效路径**:门户 `/apps/<key>/<rest>`(PROD,platform 侧 5s 缓存)与应用子域/自定义站点(网关读 routes.lua 的 `csp` 表,2s 热载)。壳路由 `/apps/<key>` 与 API 不放宽。
|
||||
- `connectSrc`:来源白名单 `scheme://host[:port]`(可 `*.` 前缀,≤8 条,无路径/查询/裸 `*`)。
|
||||
- `wasmUnsafeEval`:`script-src` 追加 `'wasm-unsafe-eval'`(wasm 编译必需,**不是** `'unsafe-eval'`,不放行 JS eval)。
|
||||
- `blob`:`connect-src`/`script-src` 追加 `blob:`,并下发 `worker-src 'self' blob:`、`img-src 'self' blob: data:`。
|
||||
- **加载方式约束**:平台**不**放开跨域 `script-src`/`worker-src` origin。核心 JS 必须先用 `fetch`(`connectSrc`)下载,再用 `URL.createObjectURL` 物化成 Blob 执行(`blob: true`);外链 `<script src=https://…>`、`new Worker(https://…)`、Worker 内 `importScripts(https://…)` 都会被 PROD CSP 拦。
|
||||
- **生效路径**:门户 `/apps/<key>/<rest>`(PROD,platform 侧 5s 缓存)与应用子域/自定义站点(网关读 routes.lua 的 `csp`/`cspPolicy` 表,2s 热载)。壳路由 `/apps/<key>` 与 API 不放宽。
|
||||
- **发布**:CI 用钉版 `minio/mc` 镜像 `mc mirror` 到桶 + 匿名读自检;模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job。
|
||||
|
||||
## 8. 令牌(TDT)与跨应用交换
|
||||
|
||||
@@ -33,17 +33,16 @@ curl -sSL https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigre
|
||||
1. dist 只保留前端壳(index.html + 它直接引用的 JS/CSS),核心资源放 `assets/` 目录(不进 dist)。
|
||||
2. CI 用钉版 `minio/mc` 镜像 mirror:`apps/<key>/<version>/`,版本化路径 + immutable 缓存;旧版本保留供回滚。
|
||||
3. 桶配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;CI 做匿名读自检。
|
||||
4. manifest 按指令声明(仅 micro,治理字段 → 202 审核):
|
||||
4. manifest 声明(仅 micro,三项都是治理字段 → 202 审核):
|
||||
```json
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"],
|
||||
"imgSrc": ["blob:"]
|
||||
"wasmUnsafeEval": true,
|
||||
"blob": true
|
||||
}
|
||||
```
|
||||
来源只能是 `blob:` 或 `scheme://host[:port]`;platform 基线已固定 `script-src 'self' 'wasm-unsafe-eval'`(wasm 编译必需)。
|
||||
5. 指令对应关系:核心用 `fetch` 加载 → `connectSrc`;外链/动态脚本与 Worker 内 `importScripts` → `scriptSrc`;`new Worker`(含 blob)→ `workerSrc`;blob 图片/音视频 → `imgSrc`/`mediaSrc`。外链 stylesheet 仍未放开。
|
||||
`connectSrc` 只能是 `scheme://host[:port]`;`wasmUnsafeEval` 放行 wasm 编译(不含 JS eval);`blob` 放行 connect/script/worker/img 的 `blob:`(img 另含 `data:`)。
|
||||
5. 加载方式:核心 JS 用 `fetch` 下载(`connectSrc`),再 `URL.createObjectURL` 物化成 Blob 执行(`blob`)。平台**不支持**跨域 `<script src>` / `new Worker(https://…)` / `importScripts(https://…)`——这些会被 PROD CSP 拦。外链 stylesheet 也未放开。
|
||||
6. 模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job(未配 `TS_ASSETS_BUCKET` 时自动跳过)。
|
||||
|
||||
### 凭证与配置
|
||||
|
||||
@@ -22,17 +22,17 @@ bash .agents/skills/app-dev/scripts/check-app.sh .
|
||||
## 大产物红线(dist tgz > 64 MiB)
|
||||
|
||||
- `publish --dist` 的 tgz 请求体硬上限 **64 MiB**;超限直接 413,改配置不解决。已压缩的 wasm / `.data` 几乎压不动。
|
||||
- 超限时:核心资源放公共读对象存储(路径 `apps/<key>/<version>/`),`dist` 只留前端壳,manifest 按指令声明:
|
||||
- 超限时:核心资源放公共读对象存储(路径 `apps/<key>/<version>/`),`dist` 只留前端壳,manifest 声明:
|
||||
```json
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"]
|
||||
"wasmUnsafeEval": true,
|
||||
"blob": true
|
||||
}
|
||||
```
|
||||
该字段仅 `type=micro` 可声明,**治理字段**(变更 202 进审核队列);来源只能是 `blob:` 或 `scheme://host[:port]`。
|
||||
- 平台基线固定带 `script-src 'self' 'wasm-unsafe-eval'`(wasm 编译必需,不解锁 JS eval);`connectSrc` 管 fetch/XHR/WebSocket/wasm 流式加载,`workerSrc` 管 `new Worker`,`scriptSrc` 管动态脚本与 Worker 内 `importScripts`,`imgSrc`/`mediaSrc` 管 blob 图片/音视频。
|
||||
- 外链 stylesheet 仍未放开(平台没有 `style-src` 例外),样式必须进 dist。
|
||||
该字段仅 `type=micro` 可声明,三项都是**治理字段**(变更 202 进审核队列)。
|
||||
- 三项默认全关:`connectSrc` 放开 fetch/XHR/WebSocket 的目标 origin;`wasmUnsafeEval` 放开 wasm 编译(不是 JS eval);`blob` 放开 connect/script/worker/img 的 `blob:`(img 另含 `data:`)。
|
||||
- 平台**不支持**跨域直载脚本/Worker:核心 JS 必须 `fetch` 下来再 `URL.createObjectURL` 成 Blob 执行;外链 `<script src>`、`new Worker(https://…)`、`importScripts(https://…)` 都会被拦。外链 stylesheet 也未放开。
|
||||
- 桶必须配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;只有公共读不够(带 `Range` 的 fetch 会触发预检)。
|
||||
|
||||
## 其他硬约束(详见 skill)
|
||||
|
||||
@@ -75,10 +75,10 @@ die 消息统一三段式:是什么 + 为什么(后果) + 怎么办/绕过
|
||||
| 网关 413 但平台没日志 | 网关 `client_max_body_size` 先拦(同 64 MiB) | 同上,不是配置问题,是产物形态问题 |
|
||||
| PROD 下 `fetch` 外部桶被 CSP 拦(`connect-src`) | manifest 未声明 `embedCsp.connectSrc`,或声明变更还在审核(202) | 补声明并等批准;确认平台侧 ≤5s / 网关 2s 已收敛 |
|
||||
| 外部资源请求 OPTIONS 预检失败 | 桶没配 CORS(只开了公共读) | 配 `Allow-Headers: Range`、`Expose-Headers: Content-Range` 等 |
|
||||
| wasm 编译被 CSP 拦(`WebAssembly.instantiate` 报 CSP) | 平台版本早于 v0.20.7,基线还没有 `'wasm-unsafe-eval'` | 升级平台;不要用 `'unsafe-eval'` 兜底 |
|
||||
| `new Worker('https://…')` 被 CSP 拦(`worker-src`) | `embedCsp.workerSrc` 未声明该 origin | 补 `workerSrc` 并等审核 |
|
||||
| `new Worker(blob:)` / Emscripten 线程被拦 | `embedCsp.workerSrc` 未声明 `blob:` | 加 `"workerSrc": ["blob:"]` |
|
||||
| Worker 内 `importScripts(https://…)` 被拦(`script-src`) | `embedCsp.scriptSrc` 未声明核心 JS 的 origin | 加 `"scriptSrc": ["https://core.example.cn"]` |
|
||||
| wasm 编译被 CSP 拦(`WebAssembly.instantiate` 报 CSP) | 未声明 `embedCsp.wasmUnsafeEval: true`(三项默认关闭) | 补 `wasmUnsafeEval` 并等审核;不要用 `'unsafe-eval'` 兜底 |
|
||||
| `new Worker('https://…')` 被 CSP 拦 | 平台不支持跨域 Worker 直载(未放开 `worker-src` origin) | 改成 `fetch` 核心 + `URL.createObjectURL`,并开 `blob: true` |
|
||||
| `new Worker(blob:)` / Emscripten 线程被拦 | 未声明 `embedCsp.blob: true` | 加 `"blob": true` |
|
||||
| Worker 内 `importScripts(https://…)` 被拦 | 平台不支持跨域脚本直载(未放开 `script-src` origin) | 改成 `fetch` + Blob URL(`blob: true`) |
|
||||
| 外链 stylesheet 被 CSP 拦 | 平台没有 `style-src` 例外 | 样式必须进 dist(同源) |
|
||||
|
||||
## 发布 / CI
|
||||
|
||||
@@ -8,18 +8,10 @@ ROOT="${1:-.}"
|
||||
cd "$ROOT" || exit 2
|
||||
|
||||
DIE=0; WARN=0
|
||||
HAS_EMBED=0; EMBED_CONNECT=""; EMBED_SCRIPT=""; EMBED_WORKER=""; EMBED_IMG=""; EMBED_MEDIA=""
|
||||
HAS_EMBED=0; EMBED_CONNECT=""; EMBED_WASM=0; EMBED_BLOB=0
|
||||
die() { echo "DIE $*"; DIE=$((DIE + 1)); }
|
||||
warn() { echo "WARN $*"; WARN=$((WARN + 1)); }
|
||||
ok() { echo "ok $*"; }
|
||||
origin_of() { printf '%s\n' "$1" | sed -E 's#^(https?://[^/]+).*#\1#'; }
|
||||
has_src() {
|
||||
local t
|
||||
for t in $1; do
|
||||
[ "$t" = "$2" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
KEY_RE='^[a-z0-9][a-z0-9-]{0,62}$'
|
||||
SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
||||
@@ -72,32 +64,34 @@ if (pages !== undefined) {
|
||||
else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience);
|
||||
});
|
||||
}
|
||||
// 大产物外置 / wasm 运行时:embedCsp 五条指令的来源集合与平台校验保持一致。
|
||||
// 大产物外置 / wasm 运行时:embedCsp 三项(connectSrc / wasmUnsafeEval / blob)与平台校验一致。
|
||||
if (o.embedCsp !== undefined) {
|
||||
console.log('HAS_EMBED=yes');
|
||||
if ((o.type ?? 'service') !== 'micro') console.log('BAD_EMBED=non-micro');
|
||||
const ec = o.embedCsp;
|
||||
if (!ec || typeof ec !== 'object' || Array.isArray(ec)) {
|
||||
console.log('BAD_EMBED=not-object');
|
||||
} else {
|
||||
const originRe = /^https?:\/\/(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$/;
|
||||
const dirs = [['connectSrc', 'CONNECT'], ['scriptSrc', 'SCRIPT'], ['workerSrc', 'WORKER'], ['imgSrc', 'IMG'], ['mediaSrc', 'MEDIA']];
|
||||
let total = 0;
|
||||
for (const [key, tag] of dirs) {
|
||||
const arr = o.embedCsp && o.embedCsp[key];
|
||||
if (arr === undefined) continue;
|
||||
if (!Array.isArray(arr)) { console.log('BAD_EMBED=' + key + ':not-array'); continue; }
|
||||
if (arr.length > 8) console.log('BAD_EMBED=' + key + ':too-many:' + arr.length);
|
||||
total += arr.length;
|
||||
const arr = ec.connectSrc;
|
||||
if (arr !== undefined) {
|
||||
if (!Array.isArray(arr)) console.log('BAD_EMBED=connectSrc:not-array');
|
||||
else {
|
||||
if (arr.length > 8) console.log('BAD_EMBED=connectSrc:too-many:' + arr.length);
|
||||
const seen = new Set();
|
||||
arr.forEach((s, i) => {
|
||||
if (typeof s !== 'string' || s.length > 253 || (s !== 'blob:' && !originRe.test(s))) {
|
||||
console.log('BAD_EMBED=' + key + '[' + i + ']:' + s);
|
||||
} else if (seen.has(s)) {
|
||||
console.log('BAD_EMBED=' + key + ':dup:' + s);
|
||||
} else {
|
||||
seen.add(s);
|
||||
console.log('EMBED_' + tag + '=' + s);
|
||||
}
|
||||
if (typeof s !== 'string' || s.length > 253 || !originRe.test(s)) console.log('BAD_EMBED=connectSrc[' + i + ']:' + s);
|
||||
else if (seen.has(s)) console.log('BAD_EMBED=connectSrc:dup:' + s);
|
||||
else { seen.add(s); console.log('EMBED_CONNECT=' + s); }
|
||||
});
|
||||
}
|
||||
if (total > 20) console.log('BAD_EMBED=too-many-total:' + total);
|
||||
}
|
||||
for (const flag of ['wasmUnsafeEval', 'blob']) {
|
||||
if (ec[flag] === undefined) continue;
|
||||
if (typeof ec[flag] !== 'boolean') console.log('BAD_EMBED=' + flag + ':not-boolean');
|
||||
else if (ec[flag]) console.log('EMBED_' + (flag === 'blob' ? 'BLOB' : 'WASM') + '=1');
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
@@ -146,36 +140,39 @@ if acl is not None:
|
||||
print(f'BAD_ACL={i}')
|
||||
elif p.get('audience') is not None and p['audience'] not in ('admin', 'viewer', 'all'):
|
||||
print(f"BAD_ACL_AUD={i}:{p['audience']}")
|
||||
# 大产物外置 / wasm 运行时:embedCsp 五条指令的来源集合与平台校验保持一致。
|
||||
# 大产物外置 / wasm 运行时:embedCsp 三项(connectSrc / wasmUnsafeEval / blob)与平台校验一致。
|
||||
if 'embedCsp' in o:
|
||||
print('HAS_EMBED=yes')
|
||||
if (o.get('type') or 'service') != 'micro':
|
||||
print('BAD_EMBED=non-micro')
|
||||
origin_re = re.compile(r'^https?://(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$')
|
||||
dirs = [('connectSrc', 'CONNECT'), ('scriptSrc', 'SCRIPT'), ('workerSrc', 'WORKER'), ('imgSrc', 'IMG'), ('mediaSrc', 'MEDIA')]
|
||||
total = 0
|
||||
ec = o.get('embedCsp')
|
||||
for key, tag in dirs:
|
||||
arr = ec.get(key) if isinstance(ec, dict) else None
|
||||
if arr is None:
|
||||
continue
|
||||
if not isinstance(ec, dict):
|
||||
print('BAD_EMBED=not-object')
|
||||
else:
|
||||
origin_re = re.compile(r'^https?://(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*(:[0-9]{1,5})?$')
|
||||
arr = ec.get('connectSrc')
|
||||
if arr is not None:
|
||||
if not isinstance(arr, list):
|
||||
print(f'BAD_EMBED={key}:not-array')
|
||||
continue
|
||||
print('BAD_EMBED=connectSrc:not-array')
|
||||
else:
|
||||
if len(arr) > 8:
|
||||
print(f'BAD_EMBED={key}:too-many:{len(arr)}')
|
||||
total += len(arr)
|
||||
print(f'BAD_EMBED=connectSrc:too-many:{len(arr)}')
|
||||
seen = set()
|
||||
for i, s in enumerate(arr):
|
||||
if not isinstance(s, str) or len(s) > 253 or (s != 'blob:' and not origin_re.match(s)):
|
||||
print(f'BAD_EMBED={key}[{i}]:{s}')
|
||||
if not isinstance(s, str) or len(s) > 253 or not origin_re.match(s):
|
||||
print(f'BAD_EMBED=connectSrc[{i}]:{s}')
|
||||
elif s in seen:
|
||||
print(f'BAD_EMBED={key}:dup:{s}')
|
||||
print(f'BAD_EMBED=connectSrc:dup:{s}')
|
||||
else:
|
||||
seen.add(s)
|
||||
print(f'EMBED_{tag}={s}')
|
||||
if total > 20:
|
||||
print(f'BAD_EMBED=too-many-total:{total}')
|
||||
print(f'EMBED_CONNECT={s}')
|
||||
for flag in ('wasmUnsafeEval', 'blob'):
|
||||
if flag not in ec:
|
||||
continue
|
||||
if not isinstance(ec[flag], bool):
|
||||
print(f'BAD_EMBED={flag}:not-boolean')
|
||||
elif ec[flag]:
|
||||
print('EMBED_' + ('BLOB' if flag == 'blob' else 'WASM') + '=1')
|
||||
EOF
|
||||
else
|
||||
warn "无 node/python3,跳过 manifest 语义检查(只查文本项)"
|
||||
@@ -207,13 +204,11 @@ EOF
|
||||
grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')"
|
||||
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
|
||||
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
|
||||
grep -q '^BAD_EMBED=' "$TMP" && die "embedCsp 非法(仅 micro;connect/script/worker/img/media 各 ≤8 条、合计 ≤20;只允许 blob: 或 scheme://host[:port],可 *.;禁止路径/裸 */分号/空白/关键字/重复):$(grep '^BAD_EMBED=' "$TMP" | sed 's/^BAD_EMBED=//' | tr '\n' ' ')"
|
||||
grep -q '^BAD_EMBED=' "$TMP" && die "embedCsp 非法(仅 micro;connectSrc ≤8 条 scheme://host[:port](可 *.,无路径/裸 */分号/空白/重复);wasmUnsafeEval/blob 必须是布尔):$(grep '^BAD_EMBED=' "$TMP" | sed 's/^BAD_EMBED=//' | tr '\n' ' ')"
|
||||
[ "$(sed -n 's/^HAS_EMBED=//p' "$TMP" | head -1)" = "yes" ] && HAS_EMBED=1
|
||||
EMBED_CONNECT=$(sed -n 's/^EMBED_CONNECT=//p' "$TMP" | tr '\n' ' ')
|
||||
EMBED_SCRIPT=$(sed -n 's/^EMBED_SCRIPT=//p' "$TMP" | tr '\n' ' ')
|
||||
EMBED_WORKER=$(sed -n 's/^EMBED_WORKER=//p' "$TMP" | tr '\n' ' ')
|
||||
EMBED_IMG=$(sed -n 's/^EMBED_IMG=//p' "$TMP" | tr '\n' ' ')
|
||||
EMBED_MEDIA=$(sed -n 's/^EMBED_MEDIA=//p' "$TMP" | tr '\n' ' ')
|
||||
[ "$(sed -n 's/^EMBED_WASM=//p' "$TMP" | head -1)" = "1" ] && EMBED_WASM=1
|
||||
[ "$(sed -n 's/^EMBED_BLOB=//p' "$TMP" | head -1)" = "1" ] && EMBED_BLOB=1
|
||||
fi
|
||||
rm -f "$TMP"
|
||||
fi
|
||||
@@ -424,7 +419,7 @@ fi
|
||||
# I5 声明与用点对得上;I7 外置上传 job(只提示,静态检查无法证明桶侧状态)。
|
||||
if [ "$HAS_EMBED" = 1 ]; then
|
||||
MISSING=0
|
||||
for origin in $EMBED_CONNECT $EMBED_SCRIPT $EMBED_WORKER $EMBED_IMG $EMBED_MEDIA; do
|
||||
for origin in $EMBED_CONNECT; do
|
||||
if ! grep -rqF "$origin" web/src web/index.html "$ENTRY" 2>/dev/null; then
|
||||
MISSING=1
|
||||
fi
|
||||
@@ -438,39 +433,41 @@ if [ "$HAS_EMBED" = 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# I6:跨域加载必须按指令声明(PROD CSP 会拦)。
|
||||
# I6:加载方式必须落在平台能力内(PROD CSP)。
|
||||
# 只对 micro 有意义——service 没有同源托管页面,Node 侧外呼不受浏览器 CSP 约束。
|
||||
if [ "$IS_MICRO" = 1 ]; then
|
||||
# 外链 <script src> / 动态 import() / importScripts():origin 必须在 scriptSrc。
|
||||
# 平台未放开 script-src/worker-src origin:跨域直载脚本/Worker 一律不支持。
|
||||
EXT_SCRIPT_URLS=$(
|
||||
{
|
||||
grep -hoE "<script[^>]+src=['\"]https?://[^'\"]+" web/index.html 2>/dev/null | sed -E "s/.*=['\"]//"
|
||||
grep -rhoE "(import|importScripts)\([[:space:]]*['\"]https?://[^'\"]+" web/src "$ENTRY" 2>/dev/null | sed -E "s/.*['\"]//"
|
||||
} | sort -u
|
||||
)
|
||||
for url in $EXT_SCRIPT_URLS; do
|
||||
o=$(origin_of "$url")
|
||||
has_src "$EMBED_SCRIPT" "$o" \
|
||||
|| die "跨域脚本未在 embedCsp.scriptSrc 声明:${o}(${url})"
|
||||
done
|
||||
|
||||
# 外域 Worker:origin 必须在 workerSrc。
|
||||
if [ -n "$EXT_SCRIPT_URLS" ]; then
|
||||
die "平台不支持跨域直载脚本(未放开 script-src origin):$(echo "$EXT_SCRIPT_URLS" | tr '\n' ' ')——改为 fetch + URL.createObjectURL(blob)"
|
||||
fi
|
||||
EXT_WORKER_URLS=$(grep -rhoE "new Worker\([[:space:]]*['\"]https?://[^'\"]+" web/src "$ENTRY" 2>/dev/null | sed -E "s/.*['\"]//" | sort -u)
|
||||
for url in $EXT_WORKER_URLS; do
|
||||
o=$(origin_of "$url")
|
||||
has_src "$EMBED_WORKER" "$o" \
|
||||
|| die "跨域 Worker 未在 embedCsp.workerSrc 声明:${o}(${url})"
|
||||
done
|
||||
if [ -n "$EXT_WORKER_URLS" ]; then
|
||||
die "平台不支持跨域直载 Worker(未放开 worker-src origin):$(echo "$EXT_WORKER_URLS" | tr '\n' ' ')——改为 fetch + blob"
|
||||
fi
|
||||
|
||||
# blob Worker(Emscripten 线程常见):同文件同时出现 new Worker 与 createObjectURL,
|
||||
# 或直接 new Worker('blob:...'),都需要 workerSrc 显式 blob:。
|
||||
BLOB_WORKER=0
|
||||
# blob 物化(Emscripten 线程常见):new Worker(blob:) 或同文件 createObjectURL。
|
||||
BLOB_USE=0
|
||||
for f in $(grep -rl 'new Worker' web/src "$ENTRY" 2>/dev/null); do
|
||||
grep -qE 'createObjectURL|new Worker\([[:space:]]*["'"'"']blob:' "$f" && BLOB_WORKER=1
|
||||
grep -qE 'createObjectURL|new Worker\([[:space:]]*["'"'"']blob:' "$f" && BLOB_USE=1
|
||||
done
|
||||
if [ "$BLOB_WORKER" = 1 ]; then
|
||||
has_src "$EMBED_WORKER" "blob:" \
|
||||
|| die "检测到 blob Worker,但 embedCsp.workerSrc 未声明 blob:(PROD CSP 会拦 new Worker(blob:))"
|
||||
if [ "$BLOB_USE" = 1 ] && [ "$EMBED_BLOB" != 1 ]; then
|
||||
die "检测到 blob Worker/脚本物化,但 embedCsp.blob 未开启(PROD CSP 会拦 blob:)"
|
||||
fi
|
||||
|
||||
# wasm 编译:默认关闭,必须显式开 wasmUnsafeEval。
|
||||
WASM_USE=0
|
||||
grep -rqE 'WebAssembly\.|\.wasm["'"'"'?]' web/src "$ENTRY" 2>/dev/null && WASM_USE=1
|
||||
if [ -d "${DIST:-}" ] && find "${DIST}" -name '*.wasm' -print -quit 2>/dev/null | grep -q .; then
|
||||
WASM_USE=1
|
||||
fi
|
||||
if [ "$WASM_USE" = 1 ] && [ "$EMBED_WASM" != 1 ]; then
|
||||
die "检测到 wasm 编译/产物,但 embedCsp.wasmUnsafeEval 未开启(PROD CSP 会拦 wasm 编译)"
|
||||
fi
|
||||
|
||||
# 外链样式不受支持(平台未放开 style-src)。
|
||||
|
||||
@@ -91,24 +91,31 @@ D=$(fixture embed-bad)
|
||||
set_embed "$D/app.manifest.json" '["https://cdn.example.com/assets"]'
|
||||
expect "embedCsp 路径非法" "$D" 'embedCsp 非法' 1
|
||||
|
||||
# 3) 跨域 Worker / 外链脚本 / blob Worker:未声明拦截;按指令声明后放行。
|
||||
# 3) 平台能力边界:跨域直载不支持;blob / wasm 需显式开关。
|
||||
D=$(fixture worker-ext)
|
||||
echo "new Worker('https://cdn.example.com/w.js');" >>"$D/web/src/main.tsx"
|
||||
expect "外域 Worker 未声明拦截" "$D" 'workerSrc 声明' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"workerSrc":["https://cdn.example.com"]}'
|
||||
expect "外域 Worker 已声明放行" "$D" 'workerSrc 声明' 0
|
||||
expect "跨域 Worker 直载拦截" "$D" '不支持跨域直载 Worker' 1
|
||||
|
||||
D=$(fixture ext-script)
|
||||
echo '<script src="https://cdn.example.com/lib.js"></script>' >>"$D/web/index.html"
|
||||
expect "外链脚本未声明拦截" "$D" 'scriptSrc 声明' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"scriptSrc":["https://cdn.example.com"]}'
|
||||
expect "外链脚本已声明放行" "$D" 'scriptSrc 声明' 0
|
||||
expect "跨域脚本直载拦截" "$D" '不支持跨域直载脚本' 1
|
||||
|
||||
D=$(fixture blob-worker)
|
||||
echo "const u = URL.createObjectURL(new Blob([])); new Worker(u);" >>"$D/web/src/main.tsx"
|
||||
expect "blob Worker 未声明拦截" "$D" 'blob Worker' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"workerSrc":["blob:"]}'
|
||||
expect "blob Worker 已声明放行" "$D" 'blob Worker' 0
|
||||
expect "blob 未开启拦截" "$D" 'embedCsp.blob 未开启' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"blob":true}'
|
||||
expect "blob 开启放行" "$D" 'embedCsp.blob 未开启' 0
|
||||
|
||||
D=$(fixture wasm-use)
|
||||
echo "WebAssembly.instantiate(bytes);" >>"$D/web/src/main.tsx"
|
||||
expect "wasm 未开启拦截" "$D" 'wasmUnsafeEval 未开启' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"wasmUnsafeEval":true}'
|
||||
expect "wasm 开启放行" "$D" 'wasmUnsafeEval 未开启' 0
|
||||
|
||||
# 2b) 开关必须是布尔。
|
||||
D=$(fixture embed-bad-flag)
|
||||
set_embed_json "$D/app.manifest.json" '{"blob":"yes"}'
|
||||
expect "开关非布尔拦截" "$D" 'embedCsp 非法' 1
|
||||
|
||||
# 4) 合规声明 + 使用点 + 小 dist → 不误报。
|
||||
D=$(fixture embed-ok)
|
||||
|
||||
Reference in new issue
Block a user