feat(app-dev): wasm CSP baseline and per-app CSP directives
- contract/checklist/AGENTS template cover connect/script/worker/img/media - check-app.sh validates directives and cross-origin loads against declarations - selftest covers external worker/script and blob worker cases
This commit is contained in:
1 parent
d05ada27f5
commit
3022d74a92
8 files changed
+156
-58
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.connectSrc`。只放开 `connect-src`(fetch/XHR/WebSocket/wasm 流式加载);Worker/动态 import/外链 script/样式/字体**不行**。桶必须配 CORS(含 `Allow-Headers: Range`)。细节见 `references/contract.md §7.1`。
|
||||
- **大产物分流(先量再发)**: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`。
|
||||
- 治理字段(`scopes`/`aclManifest`/`exchangeTargets`/`dependencies`/`mounts`/`embedCsp`)变更 → 202 进审核队列(exit 0 但**不生效**),批准前不影响已安装租户;首个版本直接生效。
|
||||
- 商店上架/授权/安装/启停/卸载是平台侧 console 动作;镜像 host 必须在 registries 白名单(空表=全拒)。
|
||||
- 输出「待平台侧操作」清单:auto_publish 开关、registries 登记、治理审核批准、授权安装——这些 Agent 做不到,不绕道不假装完成。
|
||||
|
||||
@@ -115,9 +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`;≤8 条;每项 `scheme://host[:port]`(可 `*.`);无路径/查询/裸 `*`/分号/空白/重复 | die | 正则 + 去重 |
|
||||
| I5 | 声明与用点对得上:源码有外部 origin 的 `fetch`/`instantiateStreaming`,清单未声明 → warn;声明了但源码找不到使用点 → warn(死配置) | warn | grep 源码与构建产物 |
|
||||
| I6 | 不使用 connect-src 不覆盖的跨域加载:`new Worker('https://…')`、`import('https://…')`、`importScripts('https://…')`、外链 `<script src>` / stylesheet | die | grep |
|
||||
| I4 | `embedCsp` 结构合法:仅 `type=micro`;connect/script/worker/img/media 五条指令各自 ≤8 条、合计 ≤20 条;每项只能是 `blob:` 或 `scheme://host[:port]`(可 `*.`),无路径/查询/裸 `*`/分号/空白/重复;`'self'`/`'unsafe-eval'` 等关键字拒绝(wasm 基线平台已固定) | 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 |
|
||||
| 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 已覆盖 |
|
||||
@@ -161,19 +161,25 @@ logger.info('request', { method, path, status, ms }); // 每请求一行
|
||||
- 皮肤令牌 `--sk-*` 属门户自身,不辐射 iframe 内应用;micro 应用样式自管
|
||||
- 产物发布:`aigrego-release publish --dist <dir>`,包根必须直接含 `index.html`(**tgz 请求体 ≤64 MiB**,原子替换);index.html 用**绝对路径** `/apps/<key>/...` 引用资源
|
||||
|
||||
### 7.1 大产物外置(dist 超 64 MiB 时)
|
||||
### 7.1 大产物外置与 wasm 运行时(CSP 例外)
|
||||
|
||||
- **硬边界**:平台 `POST /api/apps/:key/artifacts` 用 `http.MaxBytesReader` 卡 64 MiB(压缩后的 tgz),网关 `client_max_body_size` 同值;超限直接 413,改配置不解决(编译常量)。已压缩的 wasm / `.data` 核心再打 tgz 几乎不缩小。
|
||||
- **分流**: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` 会触发预检,只开公共读不够。
|
||||
- **manifest 声明**(仅 micro,治理字段 → 变更 202 进审核):
|
||||
- **基线(无需声明)**:所有 micro 产物页固定带 `script-src 'self' 'wasm-unsafe-eval'`——WebAssembly 编译必需,不解锁 JS `eval`。
|
||||
- **manifest 声明**(仅 micro,治理字段 → 变更 202 进审核):按指令声明额外来源,只允许 `blob:` 与 `scheme://host[:port]`(可 `*.` 前缀),每指令 ≤8 条、整份 ≤20 条、无路径/查询/裸 `*`/分号/空白;`'self'`/`'unsafe-eval'` 等关键字拒绝。
|
||||
```json
|
||||
"embedCsp": { "connectSrc": ["https://static.example.cn"] }
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"],
|
||||
"imgSrc": ["blob:"],
|
||||
"mediaSrc": ["blob:"]
|
||||
}
|
||||
```
|
||||
形态 `scheme://host[:port]`,可 `*.` 前缀,≤8 条,无路径/查询/裸 `*`/分号/空白。
|
||||
- **CSP 语义**:平台只追加 `connect-src 'self' <origins>`,覆盖 `fetch`/XHR/WebSocket/wasm 流式加载;**不覆盖** `new Worker()`、动态 `import()`、`<script src>`、外链样式/字体——这些跨域仍会被浏览器拦(`check-app.sh` I6 会拦)。
|
||||
- **生效路径**:门户 `/apps/<key>/<rest>`(PROD,platform 侧 5s 缓存)与应用子域/自定义站点(网关读 routes.lua 的 `csp` 表,2s 热载)。壳路由 `/apps/<key>` 不放宽。
|
||||
- **指令覆盖**:`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 不放宽。
|
||||
- **发布**:CI 用钉版 `minio/mc` 镜像 `mc mirror` 到桶 + 匿名读自检;模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job。
|
||||
|
||||
## 8. 令牌(TDT)与跨应用交换
|
||||
|
||||
@@ -33,12 +33,17 @@ 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"] }
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"],
|
||||
"imgSrc": ["blob:"]
|
||||
}
|
||||
```
|
||||
5. 页面用 `fetch('https://static.example.cn/apps/<key>/<version>/…')` 加载核心——这条走 `connect-src`,已放开。
|
||||
**不要**用 `new Worker(外域 URL)` / 动态 `import(外域)` / 外链 `<script>`:这些不受 `connect-src` 覆盖,平台也未放开 `script-src`/`worker-src`。
|
||||
来源只能是 `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 仍未放开。
|
||||
6. 模板见 `references/templates/gitea-app-release.yaml` 的 `assets` job(未配 `TS_ASSETS_BUCKET` 时自动跳过)。
|
||||
|
||||
### 凭证与配置
|
||||
|
||||
@@ -22,12 +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"] }
|
||||
"embedCsp": {
|
||||
"connectSrc": ["https://static.example.cn"],
|
||||
"scriptSrc": ["blob:", "https://core.example.cn"],
|
||||
"workerSrc": ["blob:"]
|
||||
}
|
||||
```
|
||||
该字段仅 `type=micro` 可声明,**治理字段**(变更 202 进审核队列)。
|
||||
- 平台只放开 `connect-src`(`fetch`/XHR/WebSocket/wasm 流式加载);`new Worker(外域)`、动态 `import(外域)`、外链 `<script>` / 样式 / 字体**仍会被 PROD CSP 拦**。
|
||||
该字段仅 `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。
|
||||
- 桶必须配 CORS:`GET/HEAD/OPTIONS`、`Allow-Headers: Range`、`Expose-Headers: Content-Length,Content-Range,ETag`;只有公共读不够(带 `Range` 的 fetch 会触发预检)。
|
||||
|
||||
## 其他硬约束(详见 skill)
|
||||
|
||||
@@ -75,7 +75,11 @@ 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` 等 |
|
||||
| `new Worker('https://…')` / 动态 `import` 外域被拦 | `connect-src` 不覆盖 Worker/模块脚本,平台也未放开 `script-src`/`worker-src` | 把这部分留在 dist 同源,或改成 `fetch` + 本地实例化 |
|
||||
| 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"]` |
|
||||
| 外链 stylesheet 被 CSP 拦 | 平台没有 `style-src` 例外 | 样式必须进 dist(同源) |
|
||||
|
||||
## 发布 / CI
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ ROOT="${1:-.}"
|
||||
cd "$ROOT" || exit 2
|
||||
|
||||
DIE=0; WARN=0
|
||||
HAS_EMBED=0; EMBED_ORIGINS=""
|
||||
HAS_EMBED=0; EMBED_CONNECT=""; EMBED_SCRIPT=""; EMBED_WORKER=""; EMBED_IMG=""; EMBED_MEDIA=""
|
||||
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.-]+)?$'
|
||||
@@ -64,23 +72,32 @@ if (pages !== undefined) {
|
||||
else if (p.audience !== undefined && !['admin', 'viewer', 'all'].includes(p.audience)) console.log('BAD_ACL_AUD=' + i + ':' + p.audience);
|
||||
});
|
||||
}
|
||||
// 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
|
||||
// 大产物外置 / wasm 运行时:embedCsp 五条指令的来源集合与平台校验保持一致。
|
||||
if (o.embedCsp !== undefined) {
|
||||
console.log('HAS_EMBED=yes');
|
||||
if ((o.type ?? 'service') !== 'micro') console.log('BAD_EMBED=non-micro');
|
||||
const arr = o.embedCsp && o.embedCsp.connectSrc;
|
||||
if (!Array.isArray(arr)) { console.log('BAD_EMBED=not-array'); }
|
||||
else {
|
||||
console.log('EMBED_COUNT=' + arr.length);
|
||||
if (arr.length > 8) console.log('BAD_EMBED=too-many:' + arr.length);
|
||||
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 seen = new Set();
|
||||
arr.forEach((s, i) => {
|
||||
if (typeof s !== 'string' || s.length > 253 || !originRe.test(s)) console.log('BAD_EMBED=' + i + ':' + s);
|
||||
else if (seen.has(s)) console.log('BAD_EMBED=dup:' + s);
|
||||
else { seen.add(s); console.log('EMBED_ORIGIN=' + s); }
|
||||
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 (total > 20) console.log('BAD_EMBED=too-many-total:' + total);
|
||||
}
|
||||
EOF
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
@@ -129,28 +146,36 @@ 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']}")
|
||||
# 大产物外置:embedCsp.connectSrc 形态与平台校验保持一致(apps/platform/internal/pkg/appcsp)。
|
||||
# 大产物外置 / wasm 运行时:embedCsp 五条指令的来源集合与平台校验保持一致。
|
||||
if 'embedCsp' in o:
|
||||
print('HAS_EMBED=yes')
|
||||
if (o.get('type') or 'service') != 'micro':
|
||||
print('BAD_EMBED=non-micro')
|
||||
arr = (o.get('embedCsp') or {}).get('connectSrc') if isinstance(o.get('embedCsp'), dict) else None
|
||||
if not isinstance(arr, list):
|
||||
print('BAD_EMBED=not-array')
|
||||
else:
|
||||
print(f'EMBED_COUNT={len(arr)}')
|
||||
if len(arr) > 8:
|
||||
print(f'BAD_EMBED=too-many:{len(arr)}')
|
||||
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(arr, list):
|
||||
print(f'BAD_EMBED={key}:not-array')
|
||||
continue
|
||||
if len(arr) > 8:
|
||||
print(f'BAD_EMBED={key}:too-many:{len(arr)}')
|
||||
total += len(arr)
|
||||
seen = set()
|
||||
for i, s in enumerate(arr):
|
||||
if not isinstance(s, str) or len(s) > 253 or not origin_re.match(s):
|
||||
print(f'BAD_EMBED={i}:{s}')
|
||||
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}')
|
||||
elif s in seen:
|
||||
print(f'BAD_EMBED=dup:{s}')
|
||||
print(f'BAD_EMBED={key}:dup:{s}')
|
||||
else:
|
||||
seen.add(s)
|
||||
print(f'EMBED_ORIGIN={s}')
|
||||
print(f'EMBED_{tag}={s}')
|
||||
if total > 20:
|
||||
print(f'BAD_EMBED=too-many-total:{total}')
|
||||
EOF
|
||||
else
|
||||
warn "无 node/python3,跳过 manifest 语义检查(只查文本项)"
|
||||
@@ -182,9 +207,13 @@ 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;≤8 条;scheme://host[:port],可 *.;禁止路径/裸 */分号/空白/重复):$(grep '^BAD_EMBED=' "$TMP" | sed 's/^BAD_EMBED=//' | tr '\n' ' ')"
|
||||
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' ' ')"
|
||||
[ "$(sed -n 's/^HAS_EMBED=//p' "$TMP" | head -1)" = "yes" ] && HAS_EMBED=1
|
||||
EMBED_ORIGINS=$(sed -n 's/^EMBED_ORIGIN=//p' "$TMP" | tr '\n' ' ')
|
||||
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' ' ')
|
||||
fi
|
||||
rm -f "$TMP"
|
||||
fi
|
||||
@@ -395,7 +424,7 @@ fi
|
||||
# I5 声明与用点对得上;I7 外置上传 job(只提示,静态检查无法证明桶侧状态)。
|
||||
if [ "$HAS_EMBED" = 1 ]; then
|
||||
MISSING=0
|
||||
for origin in $EMBED_ORIGINS; do
|
||||
for origin in $EMBED_CONNECT $EMBED_SCRIPT $EMBED_WORKER $EMBED_IMG $EMBED_MEDIA; do
|
||||
if ! grep -rqF "$origin" web/src web/index.html "$ENTRY" 2>/dev/null; then
|
||||
MISSING=1
|
||||
fi
|
||||
@@ -409,15 +438,44 @@ if [ "$HAS_EMBED" = 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# I6:connect-src 不覆盖 Worker / 动态 import / 外链 script 与样式(PROD CSP 会拦)。
|
||||
# I6:跨域加载必须按指令声明(PROD CSP 会拦)。
|
||||
# 只对 micro 有意义——service 没有同源托管页面,Node 侧外呼不受浏览器 CSP 约束。
|
||||
if [ "$IS_MICRO" = 1 ]; then
|
||||
EXT_JS=$(grep -rnE "new Worker\([[:space:]]*['\"]https?://|import\([[:space:]]*['\"]https?://|importScripts\([[:space:]]*['\"]https?://" web/src "$ENTRY" 2>/dev/null | head -3)
|
||||
HTML_JS=$(grep -nE "<script[^>]+src=['\"]https?://" web/index.html 2>/dev/null | head -3)
|
||||
HTML_CSS=$(grep -nE "<link[^>]+rel=['\"]stylesheet['\"][^>]+href=['\"]https?://|<link[^>]+href=['\"]https?://[^>]+rel=['\"]stylesheet['\"]" web/index.html 2>/dev/null | head -3)
|
||||
if [ -n "$EXT_JS$HTML_JS$HTML_CSS" ]; then
|
||||
die "跨域加载用了 connect-src 不覆盖的方式(Worker/动态 import/外链 script|stylesheet),PROD CSP 会拦:$(printf '%s\n' "$EXT_JS" "$HTML_JS" "$HTML_CSS" | head -3 | tr '\n' ' ')"
|
||||
# 外链 <script src> / 动态 import() / importScripts():origin 必须在 scriptSrc。
|
||||
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。
|
||||
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
|
||||
|
||||
# blob Worker(Emscripten 线程常见):同文件同时出现 new Worker 与 createObjectURL,
|
||||
# 或直接 new Worker('blob:...'),都需要 workerSrc 显式 blob:。
|
||||
BLOB_WORKER=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
|
||||
done
|
||||
if [ "$BLOB_WORKER" = 1 ]; then
|
||||
has_src "$EMBED_WORKER" "blob:" \
|
||||
|| die "检测到 blob Worker,但 embedCsp.workerSrc 未声明 blob:(PROD CSP 会拦 new Worker(blob:))"
|
||||
fi
|
||||
|
||||
# 外链样式不受支持(平台未放开 style-src)。
|
||||
HTML_CSS=$(grep -nE "<link[^>]+rel=['\"]stylesheet['\"][^>]+href=['\"]https?://|<link[^>]+href=['\"]https?://[^>]+rel=['\"]stylesheet['\"]" web/index.html 2>/dev/null | head -3)
|
||||
[ -n "$HTML_CSS" ] && die "外链 stylesheet 不受 embedCsp 支持(平台未放开 style-src),把样式放进 dist:$HTML_CSS"
|
||||
fi
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
|
||||
@@ -45,15 +45,20 @@ TS
|
||||
|
||||
# set_embed <manifest> <json-array>:写入 embedCsp.connectSrc(node 优先,退化 python3)。
|
||||
set_embed() {
|
||||
local f="$1" origins="$2"
|
||||
set_embed_json "$1" "{\"connectSrc\":$2}"
|
||||
}
|
||||
|
||||
# set_embed_json <manifest> <embedCsp-json>:写入任意 embedCsp 对象。
|
||||
set_embed_json() {
|
||||
local f="$1" body="$2"
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node -e 'const fs=require("fs");const [p,src]=process.argv.slice(1);const o=JSON.parse(fs.readFileSync(p));o.embedCsp={connectSrc:JSON.parse(src)};fs.writeFileSync(p,JSON.stringify(o,null,2));' "$f" "$origins"
|
||||
node -e 'const fs=require("fs");const [p,src]=process.argv.slice(1);const o=JSON.parse(fs.readFileSync(p));o.embedCsp=JSON.parse(src);fs.writeFileSync(p,JSON.stringify(o,null,2));' "$f" "$body"
|
||||
else
|
||||
python3 - "$f" "$origins" <<'PY'
|
||||
python3 - "$f" "$body" <<'PY'
|
||||
import json, sys
|
||||
p, origins = sys.argv[1], json.loads(sys.argv[2])
|
||||
p, body = sys.argv[1], json.loads(sys.argv[2])
|
||||
o = json.load(open(p))
|
||||
o['embedCsp'] = {'connectSrc': origins}
|
||||
o['embedCsp'] = body
|
||||
json.dump(o, open(p, 'w'), ensure_ascii=False, indent=2)
|
||||
PY
|
||||
fi
|
||||
@@ -69,7 +74,7 @@ expect() {
|
||||
echo "ok $name"
|
||||
else
|
||||
BAD=$((BAD + 1))
|
||||
echo "FAIL $name(期望命中=$want 模式=$pat)"
|
||||
echo "FAIL ${name}(期望命中=$want 模式=${pat})"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
@@ -86,10 +91,24 @@ D=$(fixture embed-bad)
|
||||
set_embed "$D/app.manifest.json" '["https://cdn.example.com/assets"]'
|
||||
expect "embedCsp 路径非法" "$D" 'embedCsp 非法' 1
|
||||
|
||||
# 3) 外域 Worker → connect-src 不覆盖。
|
||||
# 3) 跨域 Worker / 外链脚本 / blob Worker:未声明拦截;按指令声明后放行。
|
||||
D=$(fixture worker-ext)
|
||||
echo "new Worker('https://cdn.example.com/w.js');" >>"$D/web/src/main.tsx"
|
||||
expect "外域 Worker 拦截" "$D" 'connect-src 不覆盖' 1
|
||||
expect "外域 Worker 未声明拦截" "$D" 'workerSrc 声明' 1
|
||||
set_embed_json "$D/app.manifest.json" '{"workerSrc":["https://cdn.example.com"]}'
|
||||
expect "外域 Worker 已声明放行" "$D" 'workerSrc 声明' 0
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 4) 合规声明 + 使用点 + 小 dist → 不误报。
|
||||
D=$(fixture embed-ok)
|
||||
|
||||
Reference in new issue
Block a user