diff --git a/AGENTS.md b/AGENTS.md index 7e9610f..54802e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,10 @@ This file is mandatory for AI and coding agents working in this repository. Read - Web Tools is an anonymous-access, self-hosted Vue 3 + TypeScript browser-local tools application with a zero-dependency Node.js static/configuration server. - The default production port is `9000`. Port `8081` is only the local Vite development proxy target. - The application is anonymous. Do not add login, accounts, favorites, sponsorship, analytics, telemetry, advertising, CDN assets, or a database. +- A deployment may place Cloudflare in front of the origin. The server's per-response CSP nonce exists only to support Cloudflare JavaScript Detections without `unsafe-inline`; it is not permission to add application analytics, telemetry, or remote tool processing. - Tool inputs, keys, files, images, and results must remain inside the current browser tab. Do not send them to the Node server, a third-party API, or any remote service. -- `/setting` is a fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order. +- `/tools/setting` is the fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order. +- The first-run default category order is `encoding`, `checksum`, `crypto`, `formatting`, `image`; an existing saved navigation file keeps its custom order and is never reset by a release. - The server persists only navigation configuration. Preserve `data/navigation.json` and the configured external data directory; writes must remain temporary-file plus atomic-replace operations. - Multiple users and tabs may use every tool concurrently. Their calculation state must remain independent; only navigation order is globally shared. @@ -34,14 +36,14 @@ This file is mandatory for AI and coding agents working in this repository. Read ## Stable tool behavior -- ASCII/Hex uses UTF-8. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes. +- UTF-8 / Hex has the permanent tool ID `utf8-hex` and route `/tools/utf8-hex`. It uses strict UTF-8 conversion. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes. - Base64 supports standard and URL-safe input. Decoded output supports strict UTF-8, continuous Hex, and `0xNN, ...` byte arrays. Reject invalid characters, padding placement, lengths, and non-canonical trailing bits. -- AES-128-CMAC follows RFC 4493. The key is exactly 16 bytes of Hex and accepts continuous input or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. Hex messages use the same parser. -- CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. Combined data is ordered by address without filling gaps, and per-block results remain available. +- AES-CMAC accepts exactly 16, 24, or 32 key bytes for AES-128, AES-192, or AES-256, defaulting to 16 bytes. Never pad or truncate the key. Key and Hex-message input accepts continuous Hex or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. The CMAC tag remains exactly 16 bytes for every key size. Preserve RFC 4493 AES-128 and NIST SP 800-38B AES-192/256 vectors. +- CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. The Chinese UI labels `RefIn`/`RefOut` as input/output bit reversal; these flags do not reverse byte order or the complete input. Combined data is ordered by address without filling gaps, and per-block results remain available. - Firmware Hash accepts Intel HEX and computes SHA-256 and SHA-512. Effective-data mode concatenates sorted blocks without gap filling. Block-header mode prepends exactly two binary uint32 values, `startAddress` and data-only `dataLength`, to every block. Both fields are four bytes; Big Endian is the default; total input is `Header1 || Data1 || Header2 || Data2 ...`. Never hash a textual Hex representation of the header. -- RSA allows 2048, 3072, and 4096-bit keys with public exponent 65537. Prefer Web Crypto; the node-forge fallback must be seeded from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays. -- Secure random output comes only from `crypto.getRandomValues`, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes. -- JSON/XML formatting, minification, validation, and syntax highlighting stay local. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it. +- RSA allows 2048, 3072, and 4096-bit keys with public exponent 65537. Prefer Web Crypto; dynamically load the node-forge fallback only when needed, and seed it from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays. +- Secure random output comes only from `crypto.getRandomValues`, defaults to 16 bytes, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes. Missing or failed cryptographic sources must fail closed without a pseudorandom fallback. +- JSON/XML formatting, minification, validation, and syntax highlighting stay local. XML parser failures are identified by the browser parser-error namespace; a valid user element named `parsererror` must not be rejected. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it. - PNG/ICO processing stays local and must enforce both compressed-file and decoded-pixel limits before costly rendering or allocation. ## Intel HEX and asynchronous safety @@ -49,6 +51,8 @@ This file is mandatory for AI and coding agents working in this repository. Read - Treat every address, length, end-address, offset, and allocation calculation as checked uint32 arithmetic. JavaScript bitwise operators are signed 32-bit and must not be used as unchecked address arithmetic. - Validate record syntax, byte counts, checksums, control-record lengths/addresses, EOF placement, extended addresses, and the `0xFFFFFFFF` boundary. - Sort records by absolute address, merge contiguous records, deduplicate identical overlaps, and reject conflicting overlaps. Enforce record, block, and effective-byte limits before allocating combined buffers. +- The default Intel HEX effective-data allocation limit is 64 MiB (`DEFAULT_MAX_INTEL_HEX_BYTES`); keep it aligned with `MAX_FIRMWARE_FILE_SIZE` and the limits table in `README.zh-CN.md`. +- Navigation GET normalization and PUT writes are serialized within each Node process. Do not remove that lock without replacing it with an equally strong concurrency design. - CRC and Firmware Hash may share the reviewed Intel HEX parser but must keep separate calculation logic, Worker protocols, and Vue state. - File replacement, clear, mode change, option change, repeated generation, and component unmount can invalidate asynchronous work. Terminate obsolete Workers where possible and use monotonically increasing request/version IDs. A stale Worker, file read, image decode, or key-generation result must never replace current state. - Clear the native file input value after handling a selection so choosing the same file again still emits a change event. @@ -61,15 +65,16 @@ This file is mandatory for AI and coding agents working in this repository. Read - Keep desktop controls compact. Use stable grid/flex constraints and allow controls to become full-width where appropriate on narrow screens. - Prevent horizontal page overflow, clipped controls, nested scrolling accidents, text overlap, layout shifts, and unbounded result columns. Long Hex, hashes, filenames, and table cells require explicit wrapping or contained scrolling. - Version information remains at the bottom of the sidebar and is read from `package.json`. Every page footer displays exactly `Copyright © mid All Rights Reserved.` -- Do not add a visible link to `/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI. +- Do not add a visible link to `/tools/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI. +- Every native `input`, `select`, and `textarea` must have a unique `id` and stable `name`. `TextEditor` requires an explicit `id` and uses it as the default `name`; preserve the existing implicit or explicit label association. - Preserve the application icon contract under `public/`: `favicon.svg`, a 16/32/48-frame `favicon.ico`, `apple-touch-icon.png` at 180x180, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, and `site.webmanifest`. Keep the icon visually aligned with the sidebar brand and keep the maskable artwork inside its safe zone. - The manifest is metadata/install-ready only. Do not add a Service Worker, offline cache, install prompt, splash-screen bundle, Safari mask icon, legacy `browserconfig.xml`, redundant Apple sizes, or separate 16/32 PNG favicons unless requirements explicitly change. - Preserve the share metadata contract: the single user-facing description is exactly `一个匿名访问、自托管的浏览器本地工具箱`, represented by the standard HTML description and the identical `og:description` compatibility field. Do not add a custom share image, other Open Graph or platform-specific metadata, platform crawlers, route-specific previews, tool lists, or user data. Sharing clients may fall back to the document title and existing favicon assets, but their final preview and cache behavior remain platform-controlled. -- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/setting`, both themes, console errors, and network requests. +- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/tools/setting`, both themes, console errors, and network requests. ## Adding a tool -1. Choose a permanent, unique kebab-case ID. Published IDs must not change because routes and saved navigation depend on them. +1. Choose a permanent, unique kebab-case ID. IDs must remain stable after publication because routes and saved navigation depend on them. The recorded pre-0.2.0 correction from `ascii-hex` to `utf8-hex` is the sole existing ID migration. `setting` is reserved by `/tools/setting` and cannot be used as a tool ID. 2. Add pure logic and normal, invalid, boundary, resource-limit, and known-vector tests. 3. Add one Vue component under `src/tools/` using shared components and responsive styles. 4. Add matching metadata to `shared/tool-catalog.json` and its default position to `shared/default-navigation.json`. Only change navigation `version: 1` for a schema migration, not for a new tool. @@ -77,6 +82,7 @@ This file is mandatory for AI and coding agents working in this repository. Read 6. Verify that an old saved `navigation.json` retains its order and receives the missing tool after the Node process restarts and navigation is next loaded. IDs must remain synchronized across the catalog, default navigation, icon map, and component registry. Existing catalog tests enforce this contract. +The `ascii-hex` ID is a legacy input accepted only while normalizing existing `navigation.json` files; it migrates in place to `utf8-hex`. Do not restore it as a catalog ID, component key, form-field prefix, or route. ## Deployment and release @@ -85,6 +91,9 @@ IDs must remain synchronized across the catalog, default navigation, icon map, a - The Windows update flow requires a clean `main` checkout and uses `git pull --ff-only`. Preserve external `C:\ProgramData\WebTools\data` state. - Running `web-tools.ps1` without an action is an interactive user entry point and must keep the start/update/update-without-start/exit menu. Scheduled tasks and automation must pass an explicit `start` or `update` action so they never wait for input. - New catalog data is loaded by the Node process at startup. Rebuilding `dist` alone is insufficient after catalog changes; restart the server and refresh already-open browser pages. +- Preserve the per-response 128-bit CSP nonce generated with `node:crypto`. Cloudflare parses the nonce from the response header for JavaScript Detections. Never replace it with a fixed nonce or `unsafe-inline`; keep HTML uncached/no-cache so a document and its nonce cannot become mismatched. +- Keep one-year immutable caching limited to fingerprinted files under `dist/assets/`. Stable filenames such as HTML, favicon assets, and `site.webmanifest` must use `no-cache` so a deployment can replace them safely. +- Browser navigation PUT requests carrying Fetch Metadata must be same-origin. Requests without `Sec-Fetch-Site` remain supported for non-browser administration, so document this as defense in depth and never as authentication. - If a single EXE is later authorized, use Node.js SEA with embedded read-only web/catalog assets and an external writable data directory. Add per-architecture builds, asset-read abstraction, Windows smoke tests, replacement/update instructions, and optional Authenticode signing. Derive EXE version metadata from `package.json`; do not create another manually maintained version source. Do not commit generated executables. For a release, synchronize these five tracked files: diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c2fe8..dc1a750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ 本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。 +## 0.2.1 - 2026-08-10 + +### Fixed + +- Base64 解码现在拒绝 `TQ=`、`TWE==` 等长度不完整的显式填充,同时继续接受规范的无填充 Base64 URL 输入。 +- XML 校验改为识别浏览器专用解析错误命名空间,合法的 `` 用户元素不再被误判为解析失败。 +- favicon、Web App Manifest 和其他稳定文件名资源不再使用一年不可变缓存;只有带构建指纹的 `dist/assets/` 文件使用长期 `immutable` 缓存。 + +### Security + +- 带 Fetch Metadata 的导航配置写入现在只接受 `Sec-Fetch-Site: same-origin`,同时拦截 `same-site` 与 `cross-site` 浏览器跨源请求。无该请求头的非浏览器客户端仍受支持,因此隐藏设置路径仍不构成身份认证。 + +### Verification + +- 120 条 Vitest 测试通过,新增 Base64 显式填充、XML `parsererror` 合法元素、静态资源缓存分类和 same-site 跨源写入回归覆盖。 +- Vue/TypeScript 类型检查、Vite 生产构建、Git 差异规范检查和 Windows PowerShell 启动脚本语法检查通过。 +- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器回归;深浅主题无横向溢出或控制台警告/错误。 + +## 0.2.0 - 2026-08-09 + +### Added + +- AES-CMAC 增加 AES-192 和 AES-256 密钥长度,保留 AES-128 默认值,并增加 NIST SP 800-38B 已知向量。 +- 服务端为每个响应生成独立的 128 位 CSP nonce,使 Cloudflare JavaScript Detections 无需 `'unsafe-inline'` 便可与严格 CSP 兼容。 + +### Changed + +- 安全随机数默认长度从 8 字节调整为 16 字节,并集中管理默认值和资源上下限。 +- CRC 自定义参数和结果表头改为中文在前、英文在后的双语标签;`RefIn`/`RefOut` 明确为输入/输出位反转,宽屏参数区不再压缩长标签换行。 +- CRC 与固件 Hash 的文件上传框统一悬浮、拖拽及键盘聚焦状态,并增加与后续控件的固定间距。 +- RSA 仅在 Web Crypto 不可用或失败时动态加载 `node-forge` 回退,降低现代 HTTPS 浏览器首次打开 RSA 工具时的下载量。 +- UTF-8 / Hex 的工具 ID、组件、字段前缀和固定路径统一为 `utf8-hex` 与 `/tools/utf8-hex`;旧导航配置中的 `ascii-hex` 会原位迁移,保留分类和排序。 +- 侧边栏移除无功能编号,品牌副标题改为“本地工具箱”,共享控件统一键盘焦点环和禁用态。 +- 工具管理路径调整为 `/tools/setting`;所有表单字段补充唯一 `id` 和稳定 `name`。 + +### Security + +- 随机数增加最小/最大长度、安全随机源缺失及随机源异常的失败关闭测试,不使用普通伪随机回退。 +- CSP 仍禁止任意内联脚本和第三方脚本;Cloudflare Web Analytics 保持为部署层可选功能,不加入应用代码。 +- RSA 密钥生成增加异步请求版本保护,组件卸载或状态失效后,旧结果不会覆盖当前页面状态。 + +### Verification + +- 116 条 Vitest 测试通过,覆盖 AES-128/192/256 标准向量、安全随机源失败关闭、RSA 动态回退、CSP nonce 强度及唯一性、固定工具路径、导航 ID 迁移、默认分类顺序、Intel HEX 有效数据分配上限、并发导航写入、CRC 参数防御和表单字段标识约束。 +- Vue/TypeScript 类型检查、Vite 生产构建和 Windows PowerShell 启动脚本语法检查通过。 +- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器检查;深浅主题无横向溢出或控制台警告/错误。 + ## 0.1.0 - 2026-08-09 ### Added diff --git a/README.md b/README.md index edbb283..76a2a7e 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ The authoritative application version is the `version` field in `package.json`. ## Included tools -- ASCII/UTF-8 and Hex conversion +- UTF-8 and Hex conversion at the canonical route `/tools/utf8-hex` - Base64 and Base64 URL encoding/decoding, with UTF-8, continuous Hex, and source-code byte-array output -- AES-128-CMAC (RFC 4493) +- AES-CMAC with selectable AES-128, AES-192, and AES-256 keys - CRC-8/16/32/64 presets and custom parameters for BIN and multi-block Intel HEX files - Firmware SHA-256/SHA-512 over Intel HEX effective data, optionally with an address/length header for each block - RSA 2048/3072/4096-bit key-pair generation with PEM, N/E Hex, and byte-array output -- Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form +- Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form, defaulting to 16 bytes - JSON formatting, minification, validation, and syntax highlighting - XML formatting, minification, validation, and syntax highlighting - PNG to multi-size ICO conversion @@ -22,13 +22,21 @@ The authoritative application version is the `version` field in `package.json`. ## Security and state model - Tool data is processed locally in browser memory and is not sent to the server or a third party. -- There is no login, account, favorites, sponsorship, telemetry, CDN, or database. -- `/setting` is a fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order. +- There is no login, account, favorites, sponsorship, telemetry, application-loaded CDN asset, or database. +- The Node server emits a fresh 128-bit CSP nonce on every response. Cloudflare can attach it to JavaScript Detections injected by its proxy without enabling `unsafe-inline`; this compatibility does not add application analytics or send tool inputs away from the browser. +- `/tools/setting` is the fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order. +- Existing navigation files that still contain the legacy `ascii-hex` tool ID are migrated in place to `utf8-hex`, preserving their category and position. New configurations and application routes use only `utf8-hex`. +- The first-run default category order is Encoding, Checksum, Cryptography, Formatting, and Image. A saved `navigation.json` intentionally keeps its existing custom order; it is not silently reset by an application update. - Theme selection is stored in same-origin browser `localStorage`. The only server-side application data is `navigation.json`. +- Browser navigation writes carrying Fetch Metadata are accepted only when `Sec-Fetch-Site` is `same-origin`. Clients that omit the header are still accepted, so this is defense in depth rather than authentication. - The application is suitable for a private network, VPN, or a deployment protected by an external access-control layer. Do not treat the hidden settings URL as an Internet-facing security boundary. See [README.zh-CN.md](README.zh-CN.md) for the exact algorithm contracts, input limits, concurrency behavior, and server security controls. +## Resource limits + +The browser rejects oversized or structurally dense inputs before costly allocation. The current limits are 64 MiB for firmware files and merged Intel HEX effective data, 500,000 non-empty Intel HEX records, 1,024 Intel HEX blocks, 32 MiB for compressed PNG files, 16,384 pixels per PNG side and 32,000,000 decoded pixels, 1-65,536 bytes for secure random output (default 16), and 100,000 characters/10,000 tokens for syntax highlighting. Highlighting falls back to complete plain text; it never truncates a result. + ## Docker deployment Requirements: Docker Engine 24+ and Docker Compose v2 on Linux AMD64 or ARM64. @@ -117,6 +125,10 @@ public/ browser icons and Web App Manifest New tools must keep their computation independent, register the same stable ID in the catalog, default navigation, and lazy-load registry, reuse the shared UI, define resource limits where appropriate, and include normal, invalid, boundary, and known-vector tests. Routes are generated from the tool registry; ordinary tools do not add routes manually. +RSA uses Web Crypto first and dynamically loads the bundled `node-forge` fallback chunk from the same origin only when the native provider is unavailable or fails. Tool components remain lazy-loaded; the project does not add a Service Worker or duplicate the Brotli/Gzip compression normally supplied by a production reverse proxy such as Cloudflare. + +Only fingerprinted files under `dist/assets/` receive long-lived immutable caching. HTML, icons, the Manifest, and other stable filenames use `no-cache` so deployments can update them without waiting for a one-year browser cache to expire. XML validation identifies browser parser failures by their dedicated namespace, so a valid user element named `parsererror` is not rejected. + The browser icon set follows the sidebar brand and includes SVG, multi-frame ICO, Apple touch, 192/512, and maskable assets. The manifest supplies metadata only: the project deliberately has no Service Worker, offline cache, install prompt, or splash-screen bundle, so a deployment update cannot be hidden by application-managed stale caches. The page exposes one product description through both the standard HTML description and an identical `og:description` compatibility field. It has no custom share image, other Open Graph metadata, platform crawler, or platform-specific fields. Sharing clients may fall back to the document title and existing favicons, but each platform controls its final preview and cache refresh behavior. diff --git a/README.zh-CN.md b/README.zh-CN.md index 37ba4fb..aadaaef 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,21 +4,23 @@ 一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。 -当前应用版本:`0.1.0`。 +当前应用版本:`0.2.1`。 ## 内置工具 -- ASCII/UTF-8 与 Hex 互转 +- UTF-8 与 Hex 互转,固定路径为 `/tools/utf8-hex` - Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组 -- AES-128-CMAC(RFC 4493) +- AES-CMAC,支持 AES-128、AES-192 和 AES-256 密钥 - CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX - 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头 - RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组 -- 密码学安全随机数,支持连续 Hex、空格 Hex 和代码字节数组 +- 密码学安全随机数,默认 16 字节,支持连续 Hex、空格 Hex 和代码字节数组 - JSON 格式化、压缩、校验和语法着色 - XML 格式化、压缩、校验和语法着色 - PNG 转多尺寸 ICO +首次部署且尚未保存过自定义配置时,默认分类顺序为“编码转换 → 校验计算 → 加密计算 → 格式化 → 图像处理”。已有 `data/navigation.json` 会保留用户排序,不会因升级被默认顺序覆盖。 + ## 交付物与源码 容器交付 ZIP 同时包含源码、测试、生产构建结果和部署文件,不是只有压缩后的网页。主要目录如下: @@ -29,7 +31,7 @@ src/ Vue 3 前端源码 lib/ 可测试的纯计算与浏览器辅助逻辑 tools/ 每个工具一个独立 Vue 组件 workers/ 大文件计算使用的浏览器后台线程 - views/SettingView.vue /setting 工具管理页 + views/SettingView.vue /tools/setting 工具管理页 server/ 零依赖 Node.js 静态服务和导航配置 API shared/tool-catalog.json 工具与分类的权威元数据目录 shared/default-navigation.json 新部署的默认分类和工具顺序 @@ -53,7 +55,7 @@ CHANGELOG.md 版本变更、修复和验证记录 - 路由和左侧导航由工具目录自动生成,增加普通工具时不得手工新增路由。 - 工具输入只在浏览器内存中处理,不得发送给服务端、遥测服务或第三方接口。 - 服务端只持久化导航顺序,文件为 `/data/navigation.json`,写入采用临时文件和原子替换。 -- `/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。 +- `/tools/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。 - 页面必须保持统一 UI,并兼容桌面端和移动端竖屏。 - 浏览器图标与左侧品牌标记保持一致。`favicon.svg` 是现代浏览器主图标,`favicon.ico` 内含 16/32/48 像素回退图,另提供 180 像素 Apple 图标、192/512 像素 Web App 图标和 512 像素 maskable 图标。 @@ -65,21 +67,22 @@ CHANGELOG.md 版本变更、修复和验证记录 以下行为属于已经发布的兼容性约束。修改时必须同步实现、页面提示、自动化测试和本文档,不能只调整界面文案。 -- ASCII/Hex 工具按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。 +- UTF-8 / Hex 工具的固定 ID 为 `utf8-hex`,路径为 `/tools/utf8-hex`。它按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。 - Base64 支持标准和 URL-safe 形式;解码结果可显示为严格 UTF-8、连续 Hex 或 `0x48, 0x9F, ...` 字节数组。无效字符、错误填充和非规范编码必须报错。 -- AES-128-CMAC 密钥必须恰好为 16 字节。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀;计算语义遵循 RFC 4493。 -- CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。 +- AES-CMAC 密钥长度可选 128、192 或 256 位,默认 128 位,并分别严格要求 16、24 或 32 字节;不自动补零或截断。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀。CMAC 标签始终为 128 位;AES-128 向量遵循 RFC 4493,AES-192/256 向量遵循 NIST SP 800-38B。 +- CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。`RefIn`/`RefOut` 在页面中标为输入/输出位反转,不表示字节顺序或整个数据倒序。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。 - 固件 Hash 只接受 Intel HEX,输出 SHA-256 和 SHA-512。“有效数据”与“Block 校验头”模式的确切字节流见“固件 Hash 固定语义”;默认校验头字节序为 Big Endian。 -- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,必要时使用由 `crypto.getRandomValues` 提供随机种子的 `node-forge` 回退;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。 -- 安全随机数必须来自浏览器 `crypto.getRandomValues`,长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。 -- JSON/XML 的格式化、压缩和校验均在浏览器本地完成。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。 +- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,只有本地提供者不可用或失败时才动态加载 `node-forge` 回退,其随机种子仍只来自 `crypto.getRandomValues`;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。 +- 安全随机数必须来自浏览器 `crypto.getRandomValues`,默认 16 字节,允许长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。安全随机源不可用或抛错时直接失败,不使用普通伪随机回退。 +- JSON/XML 的格式化、压缩和校验均在浏览器本地完成。合法 XML 可以使用 `parsererror` 作为元素名;只把浏览器专用解析错误命名空间中的节点视为解析失败。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。 - 文件选择、文件更换、清空、模式切换和组件卸载都可能使异步任务失效。旧 Worker、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。 ## 多人使用与状态边界 - 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。 - CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 -- `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。 +- `/tools/setting` 保存的是所有访问者共享的导航配置。同一服务进程会按进入导航操作队列的顺序串行处理读写,最后排队的保存生效;不提供版本冲突合并。 +- 旧导航配置中的历史工具 ID `ascii-hex` 会在读取时原位迁移为 `utf8-hex`,保留原分类和排序;新配置和应用路由只使用 `utf8-hex`。 - 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。 - 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。 - 深浅主题保存在当前浏览器同源 `localStorage` 中;系统剪贴板由操作系统管理。这两项不会改变工具计算结果。 @@ -93,10 +96,11 @@ CHANGELOG.md 版本变更、修复和验证记录 | CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 | | Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 | | Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 | +| Intel HEX 合并后的有效数据 | 最大 64 MiB | 合并分配前拒绝,Worker 停止解析并显示错误 | | 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 | | PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 | | PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 | -| 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 | +| 安全随机数 | 默认 16 字节,每次 1 至 65,536 字节 | 拒绝无效长度或不安全回退 | | JSON/XML 语法着色 | 最大 100,000 字符且不超过 10,000 个 token | 结果仍完整显示,但降级为纯文本以减少 DOM 节点 | | 导航配置请求体 | 最大 64 KiB | 服务端返回 400 | @@ -139,6 +143,12 @@ docker compose down 健康检查地址:`http://服务器地址:9000/healthz` +### Cloudflare 反向代理与 CSP + +服务端在每个响应的 `Content-Security-Policy` 中放入独立的 128 位密码学随机 nonce。Cloudflare JavaScript Detections 会从源站 CSP 响应头读取该 nonce,并把它加到代理注入的脚本上,因此 Bot Fight Mode 可在不启用 `'unsafe-inline'` 的情况下与严格 CSP 共存。不得把 nonce 改为固定值,也不得为消除控制台提示而放开 `'unsafe-inline'`。 + +项目自身不添加 Web Analytics、遥测或第三方脚本。Cloudflare Web Analytics 是部署层可选的浏览器 RUM,不是 CDN、WAF、DDoS 或 Bot 防护的必要条件;默认保持关闭时,Cloudflare 边缘流量统计仍然可用。若未来明确启用 Web Analytics,必须单独评审 CSP 外部脚本来源和“无统计/无遥测”文档契约。 + ## Windows Server 部署(不使用 Docker) 要求安装 Git、Node.js 22.12.0 或更高版本,并使用 PowerShell 5.1 或更高版本。以下示例假设项目位于 `C:\Services\WebTools`。 @@ -206,7 +216,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update 2. 抽象静态资源读取方式,使 Node 服务既能读取开发时的文件系统,也能读取 EXE 内嵌资源。 3. 增加 SEA 配置、注入工具(通常为 `postject`)、`build:exe` 脚本以及 Windows x64/ARM64 分架构构建。 4. 继续把 `navigation.json` 放在 EXE 外部的可写数据目录,默认使用 `C:\ProgramData\WebTools\data`,升级 EXE 时不得覆盖它。 -5. 增加 Windows Server 启动、端口、静态资源、`/setting`、更新替换和杀毒软件兼容性冒烟测试。 +5. 增加 Windows Server 启动、端口、静态资源、`/tools/setting`、更新替换和杀毒软件兼容性冒烟测试。 6. 正式对外分发时补充 EXE 图标/版本资源,并考虑 Authenticode 代码签名;未签名程序可能触发 SmartScreen 警告。 这项改造对源码体积影响不大,通常只是数十 KB 级的入口、资源适配和构建脚本,并增加少量开发依赖;真正明显的是发布产物。每个架构的 EXE 会包含 Node.js 运行时,常见为约 60-100 MB 未压缩、约 30-60 MB 压缩,具体随 Node 版本和资源大小变化。EXE 和中间产物必须保持在 `.gitignore` 或发布目录中,不提交到 Git,因此仓库不会明显膨胀;增加的主要成本是分架构构建、签名、测试和更新维护。 @@ -223,7 +233,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update 管理入口不显示在页面中,直接访问: ```text -http://服务器地址:9000/setting +http://服务器地址:9000/tools/setting ``` 可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。 @@ -245,10 +255,10 @@ docker compose restart web-tools ## 离线镜像 -若交付目录包含 `personal-web-tools-0.1.0-amd64.tar`,可直接导入: +若交付目录包含 `personal-web-tools-0.2.1-amd64.tar`,可直接导入: ```bash -docker load -i personal-web-tools-0.1.0-amd64.tar +docker load -i personal-web-tools-0.2.1-amd64.tar docker compose up -d --no-build ``` @@ -300,7 +310,7 @@ npm run check npm audit --audit-level=low ``` -`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。 +`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404 与缓存策略、导航 API、请求大小限制及跨源浏览器写入拦截。 自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。 @@ -392,7 +402,7 @@ npm audit --audit-level=low 还应在真实浏览器中检查: - 新工具的默认路径 `/tools/` 可以直接打开和刷新。 -- 左侧导航、搜索和 `/setting` 正常显示新工具。 +- 左侧导航、搜索和 `/tools/setting` 正常显示新工具。 - 已保存的旧 `navigation.json` 在服务重启并首次读取后会自动补入新工具;仅重新构建 `dist` 不会刷新已经运行的 Node 进程。 - 桌面端和约 `390 x 844` 的移动端竖屏布局正常。 - 浏览器控制台无错误,工具内容没有发往服务端。 @@ -438,12 +448,14 @@ AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或 ## 安全边界 -- 无登录、无统计、无遥测、无 CDN 和第三方网络请求。 -- `/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。 +- 应用代码无登录、无统计、无遥测、无 CDN 资产和第三方网络请求;部署时可在外层使用 Cloudflare 反向代理。 +- `/tools/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。 - 工具内容仅在浏览器内存中处理,默认不会持久化。 - 容器以非 root 用户运行,根文件系统只读,全部 Linux capabilities 已移除。 - 服务端只接受不超过 64 KiB 的导航 JSON 配置。 - 服务端只提供静态文件、`GET/PUT /api/navigation` 和 `GET /healthz`。 - 只有无扩展名且明确请求 HTML 的前端路由会回退到 `index.html`;缺失的 JS、CSS 和其他资源返回 404。 +- 浏览器发起的导航配置写入只接受 `Sec-Fetch-Site: same-origin`;缺少该请求头的非浏览器客户端仍可写入,因此这项检查不能替代身份认证或外层访问控制。 - JSON API 响应使用 `Cache-Control: no-store`,避免浏览器缓存导航和健康状态。 -- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限。 +- 只有文件名带构建指纹的 `dist/assets/` 资源使用一年 `immutable` 缓存;HTML、favicon、Manifest 和其他稳定文件名资源使用 `no-cache`,更新后会重新验证。 +- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限;每个响应的 CSP 使用独立 128 位 nonce 兼容 Cloudflare JavaScript Detections,不允许 `'unsafe-inline'`。 diff --git a/docker-compose.yml b/docker-compose.yml index 6f4acfe..deaae8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: web-tools: - image: personal-web-tools:0.1.0 + image: personal-web-tools:0.2.1 build: context: . dockerfile: Dockerfile diff --git a/package-lock.json b/package-lock.json index 27e330b..17a4230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "personal-web-tools", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "personal-web-tools", - "version": "0.1.0", + "version": "0.2.1", "dependencies": { "@lucide/vue": "^1.29.0", "@stablelib/aes": "^2.0.1", diff --git a/package.json b/package.json index 952ecc8..2810813 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "personal-web-tools", - "version": "0.1.0", + "version": "0.2.1", "private": true, "type": "module", "engines": { diff --git a/server/index.mjs b/server/index.mjs index 2b104d0..1655d57 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { createServer } from 'node:http' @@ -17,6 +18,7 @@ const serverDir = path.dirname(serverFile) const projectRoot = path.resolve(serverDir, '..') export const MAX_JSON_BODY_SIZE = 64 * 1024 export const DEFAULT_PORT = 9000 +export const CSP_NONCE_BYTES = 16 const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], @@ -32,8 +34,13 @@ const mimeTypes = new Map([ ['.woff2', 'font/woff2'], ]) +export function createContentSecurityPolicy(nonce) { + return `default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self' 'nonce-${nonce}'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'` +} + function applySecurityHeaders(response) { - response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") + const nonce = randomBytes(CSP_NONCE_BYTES).toString('base64') + response.setHeader('Content-Security-Policy', createContentSecurityPolicy(nonce)) response.setHeader('Cross-Origin-Opener-Policy', 'same-origin') response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') response.setHeader('Referrer-Policy', 'no-referrer') @@ -96,6 +103,12 @@ function acceptsHtmlNavigation(request, pathname) { return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html')) } +function isFingerprintedAsset(filePath, staticRoot) { + const relativePath = path.relative(staticRoot, filePath) + return relativePath.split(path.sep)[0] === 'assets' + && /-[A-Za-z0-9_-]{8,}(?:\.[^.]+)+$/.test(path.basename(relativePath)) +} + async function serveStatic(request, response, staticRoot) { const url = new URL(request.url, 'http://localhost') let pathname @@ -133,8 +146,8 @@ async function serveStatic(request, response, staticRoot) { response.statusCode = 200 response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream') response.setHeader('Content-Length', details.size) - if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache') - else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + if (isFingerprintedAsset(filePath, staticRoot)) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + else response.setHeader('Cache-Control', 'no-cache') if (request.method === 'HEAD') { response.end() return @@ -149,6 +162,13 @@ export async function createWebToolsServer(options = {}) { const catalog = options.catalog || await loadCatalog() const defaults = options.defaults || await loadDefaultNavigation() const logger = options.logger || console + let navigationOperation = Promise.resolve() + + function withNavigationLock(operation) { + const result = navigationOperation.then(operation, operation) + navigationOperation = result.catch(() => undefined) + return result + } return createServer(async (request, response) => { applySecurityHeaders(response) @@ -160,12 +180,13 @@ export async function createWebToolsServer(options = {}) { } if (url.pathname === '/api/navigation') { if (request.method === 'GET') { - sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults)) + sendJson(response, 200, await withNavigationLock(() => readOrCreateNavigation(navigationPath, catalog, defaults))) return } if (request.method === 'PUT') { - if (request.headers['sec-fetch-site'] === 'cross-site') { - sendJson(response, 403, { error: '不允许跨站写入配置' }) + const fetchSite = request.headers['sec-fetch-site'] + if (fetchSite && fetchSite !== 'same-origin') { + sendJson(response, 403, { error: '不允许跨源写入配置' }) return } let navigation @@ -175,7 +196,7 @@ export async function createWebToolsServer(options = {}) { sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' }) return } - await writeJsonAtomic(navigationPath, navigation) + await withNavigationLock(() => writeJsonAtomic(navigationPath, navigation)) sendJson(response, 200, navigation) return } diff --git a/server/navigation.mjs b/server/navigation.mjs index f283d5d..ffd1e63 100644 --- a/server/navigation.mjs +++ b/server/navigation.mjs @@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url' const serverDir = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(serverDir, '..') +const legacyToolIdAliases = new Map([ + ['ascii-hex', 'utf8-hex'], +]) async function readJson(filePath) { return JSON.parse(await readFile(filePath, 'utf8')) @@ -30,7 +33,9 @@ export function normalizeNavigation(input, catalog, defaults) { if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue seenCategories.add(candidate.id) const tools = [] - for (const toolId of Array.isArray(candidate.tools) ? candidate.tools : []) { + for (const savedToolId of Array.isArray(candidate.tools) ? candidate.tools : []) { + const migratedToolId = legacyToolIdAliases.get(savedToolId) + const toolId = migratedToolId && toolById.has(migratedToolId) ? migratedToolId : savedToolId if (!toolById.has(toolId) || seenTools.has(toolId)) continue seenTools.add(toolId) tools.push(toolId) diff --git a/shared/default-navigation.json b/shared/default-navigation.json index f1918c6..310d7b3 100644 --- a/shared/default-navigation.json +++ b/shared/default-navigation.json @@ -1,7 +1,7 @@ { "version": 1, "categories": [ - { "id": "encoding", "tools": ["ascii-hex", "base64"] }, + { "id": "encoding", "tools": ["utf8-hex", "base64"] }, { "id": "checksum", "tools": ["crc", "firmware-hash"] }, { "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] }, { "id": "formatting", "tools": ["json", "xml"] }, diff --git a/shared/tool-catalog.json b/shared/tool-catalog.json index 44d4d9d..0930c52 100644 --- a/shared/tool-catalog.json +++ b/shared/tool-catalog.json @@ -9,8 +9,8 @@ ], "tools": [ { - "id": "ascii-hex", - "title": "ASCII / Hex", + "id": "utf8-hex", + "title": "UTF-8 / Hex", "category": "encoding", "description": "在 UTF-8 文本与十六进制字节之间转换。", "icon": "Binary" @@ -24,9 +24,9 @@ }, { "id": "aes-cmac", - "title": "AES-128-CMAC", + "title": "AES-CMAC", "category": "crypto", - "description": "使用 128 位密钥计算消息认证码。", + "description": "使用 128、192 或 256 位 AES 密钥计算消息认证码。", "icon": "ShieldCheck" }, { diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 4f02232..bc2bd1e 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -25,17 +25,17 @@ const sections = computed(() => props.navigation.categories.map((navigationCateg