fix: 全面审计修复 — 键盘/印章库/右键/资源/CSP 加固 #1

Open
wenpai wants to merge 4 commits from fix/audit-hardening into master
16 changed files with 292 additions and 105 deletions

46
deploy.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
#
# yinji 静态站部署脚本 — 阿里云 OSS
# 只同步应上线的白名单文件,并按资源类型设置缓存头。
# 用法:
# BUCKET=oss://your-bucket ./deploy.sh [--dry-run]
# 前置: ossutil 已 config(AK/SK/endpoint)。详见 docs/DEPLOY.md
#
set -euo pipefail

BUCKET="${BUCKET:-}"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN="--dry-run"
fi

if [[ -z "$BUCKET" ]]; then
echo "错误: 需设置 BUCKET,例如 BUCKET=oss://yinji-site ./deploy.sh" >&2
exit 1
fi

if ! command -v ossutil >/dev/null 2>&1; then
echo "错误: 未找到 ossutil。请先安装并 ossutil config。见 docs/DEPLOY.md" >&2
exit 1
fi

cd "$(dirname "$0")"

# 应用代码 / 入口: 随发版变化, 不缓存(每次校验)
APP_HEADER="Cache-Control:no-cache"
# 第三方库: 版本固定, 长缓存
VENDOR_HEADER="Cache-Control:max-age=31536000"

echo "==> 同步 vendor/ (长缓存) 到 $BUCKET/vendor/"
ossutil cp -r -f vendor/ "$BUCKET/vendor/" --meta "$VENDOR_HEADER" $DRY_RUN

echo "==> 同步 src/ (不缓存) 到 $BUCKET/src/"
ossutil cp -r -f src/ "$BUCKET/src/" --meta "$APP_HEADER" $DRY_RUN

echo "==> 同步 public/ 到 $BUCKET/public/"
ossutil cp -r -f public/ "$BUCKET/public/" --meta "$APP_HEADER" $DRY_RUN

echo "==> 同步 index.html (不缓存, 入口最后传保证一致性) 到 $BUCKET/"
ossutil cp -f index.html "$BUCKET/index.html" --meta "$APP_HEADER" $DRY_RUN

echo "==> 完成。若 CDN 有旧缓存, 刷新 index.html 与 src/ 目录。"

39
docs/AUDIT-2026-07-21.md Normal file
View file

@ -0,0 +1,39 @@
# 全面审计报告 — 2026-07-21

> 方法: 4 路并行子代理审计(core / modules / wiring+UI / security),逐条交叉验证后修复。
> 提交: `fix/audit-hardening` (db73b34) | cluster-recall #5785
> 验证闭环: `node --check` 语法全检 + `yinji-cli` 冒烟/状态/批量/端到端 + UI 截图 + CSP 违规检查

## 已修复(8 类, 11 文件)

| 严重度 | 文件 | 缺陷 | 修复 |
|--------|------|------|------|
| HIGH | main.js:1071 | 键盘快捷键不判焦点: 坐标框 Backspace 删印章、方向键移印章、旋转框输不了负号、文本框 Ctrl+C/V/S 被劫持 | handler 开头加焦点守卫(INPUT/TEXTAREA/SELECT/contenteditable/isComposing → return) |
| HIGH | seal-library.js:19 | `getSavedSeals` 不校验数组,脏 localStorage 打死印章库读/写/渲染 | `Array.isArray` 校验 + `isValidSeal` 逐项过滤 + 格式异常自动重置 |
| MED | seal-library.js:260 | `loadAndApplySeal` 无 onerror + 成功 toast 在 onload 外同步弹 | toast 移入 onload、补 onerror、handler 先于 src 赋值 |
| MED | canvas-manager.js:113 / context-menu.js:42 | 右键不选中对象 → 菜单不出现或作用于旧对象 | 开 `fireRightClick`/`stopContextMenu`,菜单前 `findTarget` 命中置活动对象 |
| MED | responsive.js:70-139 | 触摸手势死代码(`_canvas` 从不赋值) + body 全子树 MutationObserver 常驻空转 | 移除死代码链(fabric 自带触摸兜底),保留响应式布局 |
| LOW | exporter/batch-processing/batch-operations/settings (×5) | blob URL `click()` 后立即 revoke → 部分浏览器下载失败 | 延迟 `setTimeout(revoke, 1000)` |
| LOW | batch-processing.js:253 | 下载名 `.replace('.pdf')` 首次匹配+区分大小写+未 sanitize | 统一 `generateSafeExportFilename` |
| MED(加固) | index.html:9 / security.js | CSP 缺 object/frame 限制、无转义助手 | CSP 加 `object-src 'none'; frame-ancestors 'none'`;补 `escapeHtml()` |
| INFO | tmp-global-patch-test-a.js | 根目录遗留测试残file | 删除 |

## 刻意不改(附理由)

- **exporter 恒 `degrees(0)`**: 旋转烘焙进位图(`getRotatedCroppedImage`) + 对象 `lockRotation:true`,正常使用不可能产生非零 angle,当前不可触发。
- **state.js `setState` 浅拷贝**: previousState 与集合/数组字段共享引用,但 batch-operations 已用 `new Set(...)` 防御拷贝规避。
- **seal id `Date.now()` 毫秒碰撞**: 手动操作极难触发。
- **pdf.js `isEvalSupported`**: CSP 无 `unsafe-eval`,eval 本就被拦。
- **SVG 白名单(image/svg+xml)**: 经确认非 XSS 向量(仅经 `<img>`/fabric image 静态渲染,不执行脚本),保留功能。
- **移除 `script-src 'unsafe-inline'`**: 需搬 index.html 内联 lucide 初始化脚本,有回归风险,建议后续单独一项处理。

## 安全结论

未发现任何可实际利用的 XSS/注入。防护可信,但可信来自"全程用 `textContent` 而非 `innerHTML` 渲染不可信数据(文件名/印章名/签名/toast/dialog)"的编码纪律,而非集中转义工具。所有 innerHTML 均为开发者硬编码静态模板。fabric 反序列化为自写、只吃应用自身内存对象,不吃外部不可信 JSON。

## 通用教训(见 cluster-recall #5785)

1. 全局键盘快捷键处理器必须判 `e.target` 是否输入控件 + `e.isComposing`。
2. localStorage 读取 `JSON.parse` 后必须校验类型(`Array.isArray`)并逐项过滤脏数据。
3. blob URL 下载应延迟 revoke。
4. `image.onload` 必配 `onerror`,成功提示放 onload 内。

75
docs/DEPLOY.md Normal file
View file

@ -0,0 +1,75 @@
# 部署 Runbook — 阿里云 OSS/COS + CDN

> yinji 是 100% 静态站(无构建步骤,无后端,文件不出浏览器)。部署即静态资源分发。
> 目标: 阿里云 OSS(或腾讯云 COS)静态网站托管 + CDN 加速 + HTTPS。

## 前置条件(阻塞项,需先落实)

| 事项 | 归属 | 说明 |
|------|------|------|
| 域名确定 | modiqi(业务) | 默认建议 `yinji.modiqi.com`;最终由 modiqi 拍板 |
| 域名备案 | modiqi/DevOps | 国内 CDN + 自有域名**必须备案**。`yinji.modiqi.com` 依赖 `modiqi.com` 已备案(子域名可复用主体备案) |
| 阿里云账号 + AK/SK | DevOps/modiqi | OSS bucket 创建与 AccessKey 属账号/基础设施层,wenpai 不持有需申领 |
| DNS 解析(CNAME 到 CDN) | DevOps | 域名 CNAME 指向 CDN 加速域名 |
| `ossutil` 工具 | wenpai(本机) | 上传工具,`ossutil config` 配 AK/SK/endpoint |

> wenpai 负责应用部署(资源同步 / 缓存策略 / vhost 逻辑);账号、AK/SK、DNS、备案属 DevOps/业务,需协同。

## 部署包边界(只上传这些)

```
index.html
src/ (全部 .js / .css)
vendor/ (fabric / pdf.js / pdf-lib / lucide)
public/ (favicon / 图标)
```

**不上传**: `docs/` `tests/` `tools/` `*.pdf`(夹具) `*.png`(截图) `AGENTS.md` `CLAUDE.md` `README.md` `.codex/` `.git/`。
`deploy.sh` 已按此白名单同步。

## 缓存策略(关键)

无构建=无内容哈希文件名,故按"是否随发版变化"分层:

| 资源 | Cache-Control | 理由 |
|------|---------------|------|
| `index.html` | `no-cache`(每次校验) | 入口,发版必须立即生效 |
| `src/**` | `no-cache` 或 `max-age=300` | 应用代码,随发版变 |
| `vendor/**` | `max-age=31536000, immutable` | 第三方库,版本固定基本不变 |

> 发版后如 CDN 有旧缓存,对 `index.html` + `src/` 做 CDN 刷新(refresh),`vendor/` 走预热/长缓存。

## 执行步骤

```bash
# 1. 配置 ossutil(一次性,AK/SK 向 DevOps 申领)
ossutil config # 填 endpoint / AccessKeyID / AccessKeySecret

# 2. 干跑确认将上传的文件集
cd ~/Projects/yinji
BUCKET=oss://<bucket-name> ./deploy.sh --dry-run

# 3. 正式同步(带缓存头)
BUCKET=oss://<bucket-name> ./deploy.sh

# 4. OSS 控制台开启"静态网站托管": 默认首页 index.html
# 5. 绑定 CDN 加速域名, 回源 OSS; 配 HTTPS 证书(LE 或已有通配符)
# 6. 强制 HTTPS + HTTP/2; index.html/src 缓存按上表设置
# 7. DNS: 域名 CNAME → CDN 加速域名(DevOps)
```

## 上线验收(不能只测首页)

```bash
DOMAIN=yinji.modiqi.com
curl -skI https://$DOMAIN/ | grep -i "http/\|cache-control" # 200 + no-cache
curl -skI https://$DOMAIN/vendor/fabric.min.js | grep -i "cache-control" # 长缓存
# 浏览器实测: 打开文件→选印章→盖章→导出 PDF/PNG→批量; 移动端布局; 无 CSP 违规(控制台)
```

> 验收标准遵循集群规范: 完整前后台流程 + 无控制台报错,不能只测首页。

## 备选

- **腾讯云 COS**: 同构,`coscli` 替 `ossutil`,静态托管 + CDN 同理。
- **海外/全球访问**: OSS 源站 + Cloudflare 前置,或直接 Cloudflare Pages(git 驱动免运维)。

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'"
/>
<title>印迹Yinji- 本地文件印章工具</title>
<meta
@ -115,6 +115,8 @@
</div>
</div>
<div class="header-actions">
<button id="undoBtn" class="icon-btn" title="撤销 (Ctrl+Z)" aria-label="撤销" disabled><i data-lucide="undo"></i></button>
<button id="redoBtn" class="icon-btn" title="重做 (Ctrl+Y)" aria-label="重做" disabled><i data-lucide="redo"></i></button>
<button id="settingsBtn" class="icon-btn" title="设置"><i data-lucide="settings"></i></button>
<button id="helpBtn" class="icon-btn" title="键盘快捷键"><i data-lucide="help-circle"></i></button>
</div>

View file

@ -5,6 +5,7 @@

import { getState } from './state.js';
import { dataUrlToUint8Array } from '../utils/data-url.js';
import { generateSafeExportFilename } from '../utils/security.js';

// ==================== State ====================

@ -250,9 +251,12 @@ async function processSingleBatchFile(fileItem) {
// Download file
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileItem.file.name.replace('.pdf', '_盖章版.pdf');
// 统一走 sanitize并按扩展名大小写不敏感、仅结尾正确改名
// 避免 "2024.pdf.report.pdf" 错位替换或大写 .PDF 不加后缀导致同名覆盖。
link.download = generateSafeExportFilename(fileItem.file, '_盖章版');
link.click();
URL.revokeObjectURL(link.href);
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
setTimeout(() => URL.revokeObjectURL(link.href), 1000);

resolve();
} catch (error) {

View file

@ -16,10 +16,32 @@ const MAX_SEALS = 20; // 最多保存20个印章
* Get all saved seals from localStorage
* @returns {Array} Array of seal objects
*/
function isValidSeal(seal) {
return Boolean(
seal &&
typeof seal === 'object' &&
typeof seal.imageData === 'string' &&
seal.imageData.length > 0
);
}

function getSavedSeals() {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
if (!data) {
return [];
}

const parsed = JSON.parse(data);
// 校验解析结果必须是数组,且逐项过滤脏数据;
// 否则被污染成 {}/字符串/数字的键会让下游 findIndex/forEach/length 全线崩溃。
if (!Array.isArray(parsed)) {
console.warn('印章库数据格式异常,已重置');
localStorage.removeItem(STORAGE_KEY);
return [];
}

return parsed.filter(isValidSeal);
} catch (error) {
console.error('读取印章库失败:', error);
window.Toast?.error('读取印章库失败');
@ -258,7 +280,6 @@ function loadAndApplySeal(sealId) {
);

const sealImageElement = new Image();
sealImageElement.src = seal.imageData;
sealImageElement.onload = () => {
// Apply all settings
if (rotationSlider && rotationInput) {
@ -278,9 +299,17 @@ function loadAndApplySeal(sealId) {
sealRotation: seal.rotation || 0,
sealBrightness: seal.brightness || 100,
});

// 成功提示必须在 onload 内:图片损坏时不能假报成功,
// 否则 state 未更新却提示成功,后续盖章会用旧/空印章。
window.Toast?.success(`已加载印章 "${seal.name}"`);
};

window.Toast?.success(`已加载印章 "${seal.name}"`);
sealImageElement.onerror = () => {
window.Toast?.error(`印章 "${seal.name}" 数据损坏,无法加载`);
};

sealImageElement.src = seal.imageData;
}
}


View file

@ -202,7 +202,8 @@ function exportSettings() {
link.download = 'yinji-settings.json';
link.click();

URL.revokeObjectURL(url);
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败
setTimeout(() => URL.revokeObjectURL(url), 1000);
window.Toast?.success('设置已导出');
}


View file

@ -905,6 +905,17 @@ function initializeEventListeners() {
onToolbarStatusChange: updateToolbarStatus,
});

// Undo / Redo buttons (disabled 状态由 window.History.updateUndoRedoButtons 维护)
const undoBtn = document.getElementById('undoBtn');
if (undoBtn) {
undoBtn.addEventListener('click', () => window.History.undo());
}

const redoBtn = document.getElementById('redoBtn');
if (redoBtn) {
redoBtn.addEventListener('click', () => window.History.redo());
}

// Help button
const helpBtn = document.getElementById('helpBtn');
if (helpBtn) {
@ -1069,6 +1080,20 @@ function handlePdfFileWithCallbacks(file) {
* @param {KeyboardEvent} e
*/
function handleKeyboardShortcuts(e) {
// 焦点在输入控件或正在中文输入时,让位给原生编辑行为,
// 否则会出现坐标框 Backspace 误删印章、方向键误移、负号/文本无法输入、Ctrl+C/V/S 被劫持等问题。
const target = e.target;
const tag = target?.tagName;
if (
e.isComposing ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target?.isContentEditable
) {
return;
}

const canvas = getCurrentPageCanvas();
const activeObject = canvas?.getActiveObject();


View file

@ -16,7 +16,8 @@ function triggerPdfDownload(bytes, filename) {
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}

/**

View file

@ -110,7 +110,14 @@ export async function initializeFabricCanvasForPage(pageNum, forceRecalculate =
const originalHeight = highResViewport.height;

const canvasEl = document.getElementById(`canvas-${pageNum}`);
const fabricCanvas = fabricCanvases[pageNum - 1] || new fabric.Canvas(canvasEl);
const fabricCanvas =
fabricCanvases[pageNum - 1] ||
new fabric.Canvas(canvasEl, {
// 允许右键选中/操作对象,并阻止浏览器原生右键菜单,
// 使自定义右键菜单能作用在实际点到的印章上。
fireRightClick: true,
stopContextMenu: true,
});

if (!fabricCanvases[pageNum - 1]) {
fabricCanvas.originalWidth = originalWidth;

View file

@ -105,7 +105,8 @@ export async function exportPDF(pdfFile) {

window.Loading.updateProgress(100, '完成!');
link.click();
URL.revokeObjectURL(link.href);
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
setTimeout(() => URL.revokeObjectURL(link.href), 1000);

window.Loading.hide();
window.Toast.success(`已开始下载:${safeFilename}`);
@ -175,7 +176,8 @@ export async function exportCurrentPageAsImage(sourceFile) {

window.Loading.updateProgress(100, '完成!');
link.click();
URL.revokeObjectURL(link.href);
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
setTimeout(() => URL.revokeObjectURL(link.href), 1000);

try {
await pdfDoc.destroy?.();

View file

@ -1,11 +1,13 @@
/**
* 响应式布局管理模块
* 处理移动端适配、触摸手势、布局切换
* 处理移动端适配、布局切换
*
* 注: 印章的触摸拖动/缩放由 fabric.js 画布原生处理。
* 早期版本这里有一套自定义 touch 手势,但依赖从未被赋值的 canvasEl._canvas
* 整条链路是死代码,且配套的全文档 MutationObserver 常驻空转,已移除。
*/

let isMobile = false;
let touchStartX = 0;
let touchStartY = 0;

/**
* 检测屏幕宽度并应用响应式布局
@ -43,100 +45,12 @@ function applyMobileLayout(mobile) {
}
}

/**
* 计算触摸两点间距离
*/
function getTouchDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}

/**
* 初始化触摸手势支持
* @param {HTMLElement} canvasEl - 画布元素
*/
export function initTouchGestures(canvasEl) {
if (!canvasEl) return;

let initialScale = 1;
let objectBeingMoved = null;

canvasEl.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
// 单指触摸:拖动
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
objectBeingMoved = canvasEl._canvas?.getActiveObject();
} else if (e.touches.length === 2) {
// 双指触摸:缩放+旋转
initialScale = getTouchDistance(e.touches[0], e.touches[1]);
e.preventDefault();
}
}, { passive: false });

canvasEl.addEventListener('touchmove', (e) => {
const canvas = canvasEl._canvas;
if (!canvas) return;

if (e.touches.length === 1) {
// 单指拖动:移动选中对象
const activeObject = canvas.getActiveObject();
if (activeObject && objectBeingMoved) {
const dx = e.touches[0].clientX - touchStartX;
const dy = e.touches[0].clientY - touchStartY;

activeObject.set({
left: activeObject.left + dx,
top: activeObject.top + dy,
});
canvas.renderAll();

touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}
} else if (e.touches.length === 2) {
// 双指缩放+旋转
e.preventDefault();
const currentScale = getTouchDistance(e.touches[0], e.touches[1]);
const scaleRatio = currentScale / initialScale;

const activeObject = canvas.getActiveObject();
if (activeObject) {
activeObject.set({
scaleX: activeObject.scaleX * scaleRatio,
scaleY: activeObject.scaleY * scaleRatio,
});
canvas.renderAll();
}

initialScale = currentScale;
}
}, { passive: false });

canvasEl.addEventListener('touchend', (e) => {
objectBeingMoved = null;
});
}

/**
* 初始化响应式布局
*/
export function initResponsive() {
detectAndApplyResponsive();
window.addEventListener('resize', detectAndApplyResponsive);

// 为所有画布初始化触摸手势
const observer = new MutationObserver(() => {
document.querySelectorAll('canvas').forEach((canvas) => {
if (!canvas._touchInitialized) {
initTouchGestures(canvas);
canvas._touchInitialized = true;
}
});
});

observer.observe(document.body, { childList: true, subtree: true });
}

/**

View file

@ -295,10 +295,17 @@ export async function addStraddleSeal() {
const imgPiece = await loadFabricImage(tempPieceCanvas.toDataURL());
imgPiece.scale(initialScale);
const scaledPieceWidth = pieceWidth * initialScale;
const scaledPieceHeight = sealImageElement.height * initialScale;
// 按页面实际高度垂直居中,替换硬编码 top:400
// 避免固定像素在小页面出界或不同尺寸页位置不一致。
const straddleTop = Math.max(
0,
(canvas.originalHeight - scaledPieceHeight) / 2
);

imgPiece.set({
left: canvas.originalWidth - scaledPieceWidth,
top: 400,
top: straddleTop,
hasControls: true,
borderColor: '#007bff',
lockMovementX: true,

View file

@ -40,7 +40,24 @@ function handleContextMenuOpen(event) {
}

const canvas = getCurrentPageCanvas();
const activeObject = canvas?.getActiveObject();
if (!canvas) {
hideContextMenu();
return;
}

// 先把右键点到的对象设为活动对象,再读 activeObject
// 否则菜单会不出现(此前没选中)或作用在上一个选中的对象上。
try {
const target = canvas.findTarget(event);
if (target && !target.isBackgroundImage) {
canvas.setActiveObject(target);
canvas.requestRenderAll();
}
} catch (error) {
// findTarget 在个别 fabric 版本可能抛错,忽略后回退到当前选中态
}

const activeObject = canvas.getActiveObject();

if (!activeObject) {
hideContextMenu();

View file

@ -60,6 +60,25 @@ export function generateSafeExportFilename(
return `document${suffix}.${normalizedExtension}`;
}

/**
* HTML 转义:任何需要把用户可控数据拼进 innerHTML 的地方都必须先过这里。
* 当前代码统一用 textContent 渲染不可信数据(安全),此助手作为纵深防御,
* 避免未来改用 innerHTML 时遗漏转义CSP 含 unsafe-inline注入即可执行
* @param {string} value
* @returns {string}
*/
export function escapeHtml(value) {
if (value === null || value === undefined) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

/**
* 初始化安全措施
* CSP 已在 HTML meta 标签中声明,无需运行时设置

View file

@ -1 +0,0 @@
export function run() { return foo.bar(); }