中文版:SECURITY.zh-CN.md — the two copies are maintained in sync.
Please do not report security issues in public issues. Preferred channels:
- GitHub private vulnerability reporting: repo page → Security tab → Report a vulnerability (the maintainer must enable Private vulnerability reporting under Settings → Code security first)
- Email: lishuai4415@gmail.com (the
authorin package.json)
Please include: affected version/commit, reproduction steps, and impact assessment. We will acknowledge receipt within 3 business days and feed progress back through the original channel.
| Version | Supported |
|---|---|
Latest master |
✅ |
| Historical tags | ❌ |
- PR-stage CI: typecheck / strict lint (including
.mjs;react-hooks/exhaustive-depsis an error and unused-disable reporting is on) / vitest / coverage gate (pnpm test:coverage, thresholds invitest.config.ts) / both explicit demo builds plus the real-mode build (build:real, whose artifact is asserted to contain no demo code) / bundle budget & style coverage / artifact secret scanning (fingerprint-verified against declared public client tokens only) / full dependency advisories with verified source patches / Playwright (CSP violations caused by third parties are not ignored) - The coverage percentage is measured over the selected include set in
vitest.config.ts—src/utils/**,src/services/**,src/demo/**,src/pages/Login/**,src/pages/System/Users/**andsrc/pages/System/Roles/**— not over the whole project. The final local figure is statements 79.38% / branches 74.48% / functions 75.55% / lines 81.51% over 54 files (pnpm test:coverage, 644 passed / 5 skipped, thresholds 60/50/60/60 unchanged); measuring whole-projectsrc/**in a one-off run gives about 47.87% statements with the config untouched, so the gate figure must not be quoted as whole-project coverage. The 5 skips are the mode-conditional branches ofscripts/real-mode-artifact.test.ts(a demo-mode artifact skips the real-mode assertions); CI setsREAL_MODE_ARTIFACT_REQUIRED=1on thebuild:realjob, where they cannot silently skip - Post-deploy: wait for the online
version.jsonto switch to this commit, then run the@smokecases against the live site - Commit stage: pre-commit (lint-staged + typecheck)
- Repository hygiene tests: committable files must not contain JWT-shaped secrets or local artifacts
Every build-time credential that can reach the public bundle is declared explicitly and verified after the build. config/config.ts wires three credentials from process.env — CESIUM_ION_TOKEN, AMAP_KEY, AMAP_SECURITY_CODE — and scripts/check-dist-secrets.mjs maps each to a typed declaration: EXPECTED_CESIUM_TOKEN (JWT-shaped Cesium ion token), EXPECTED_AMAP_KEY and EXPECTED_AMAP_SECURITY_CODE (opaque 32-hex AMap values); the legacy EXPECTED_TOKEN is accepted only as a deprecated alias for the Cesium token. The scanner fails closed when a define in config.ts or config.github.ts derives from process.env — directly or indirectly (assigned to a variable first, destructured, returned by a function, or imported from a local module that reads process.env, followed transitively) — without being registered either as a credential type or as public configuration (PUBLIC_ENV_DEFINES: the API base, the demo-mode flag and BUILD_INFO), when a define entry cannot be analysed statically (spread, computed key), when a UMI_APP_* variable other than the registered public ones (PUBLIC_UMI_APP_VARS) is present in the build environment or in .env / .env.local under the working directory — umi injects every UMI_APP_* variable into the bundle's process.env object without going through the define block, so credentials must never use that prefix — when a credential is configured at build time but left undeclared, when a declared value is not the documented shape (truncated, quoted, whitespace-padded), when the declaration differs from the value actually injected by the build, or when a declaration has no corresponding value in the artifact — the last case is evidence that the scanned tree is not the deployed build. Logs contain only SHA-256 fingerprints and file paths, never credential values.
The scan runs on the artifact that is actually published: the Docker builder stage scans dist inside the same RUN as pnpm build, before COPY --from=builder /app/dist; Vercel runs pnpm run build && node scripts/check-dist-secrets.mjs dist in one shell; the GitHub Pages workflow scans with the deployment secrets, then writes a SHA-256 integrity manifest (scripts/verify-dist-integrity.mjs: per-file path/size/sha256, file count, directory structure, total size, symlinks rejected) that travels inside the uploaded artifact and is re-verified after download, before publish — any mismatch blocks the deployment. Cloudflare Pages scans only when its dashboard build command is pnpm build:demo && node scripts/check-dist-secrets.mjs dist; the repository cannot enforce a dashboard setting, so that pipeline relies on whoever configures it. Docker receives credentials as build args, never ENV; Docker may still record build-arg values in builder metadata/provenance, so a deployment needing stronger isolation should switch to a BuildKit secret mount.
Known limits of this mechanism: an AMap value has no recognisable prefix, so a second opaque key that was neither present in the build environment nor declared cannot be identified by shape — the entry point is deliberately narrowed to the define registry of the two config files and to the UMI_APP_* allowlist (a new credential type must be registered, and an injected credential must be declared). The define analysis is a conservative text-level taint check rather than a TypeScript evaluator: it over-reports rather than under-reports, and anything it cannot follow fails the scan. The high-entropy heuristic is intentionally not used, because legitimate high-entropy strings are abundant in the artifact and would cause uncontrollable false positives. The Docker path and the three workflow files are verified statically and by running their exact shell steps locally (the Docker daemon is unavailable on the development machine); no live Docker image build, Vercel deployment or GitHub Actions run is claimed here.
One workstation caveat: umi's loadEnv (@umijs/core) unconditionally overwrites process.env from .env and then .env.local, so on a machine that has a .env.local the shell-level credential never reaches the bundle and the scanner will fail — correctly — until the value that was actually injected is declared. .env.local itself is absent from CI, Vercel and Docker contexts (.dockerignore excludes .env*, .gitignore covers /.env.*, and the file is untracked), which is the usual reason a local scan fails while CI passes.
This repository is a frontend template only — there is no real backend. The pure-frontend demo requires UMI_APP_DEMO_MODE=true explicitly. The demo never validates passwords and cannot establish trusted data isolation — permission points are enforced per request by the in-browser demo backend (which makes permission differences demonstrable), but every check runs inside the user's own browser and is not a real security boundary. The demo uses fictional data only; the login token lives in sessionStorage, and startup removes legacy localStorage tokens. In-browser data and scripts are under the user's control.
The same applies to everything the demo shows about authorization: assigning a role requires the separate system.user.role.assign permission (in user creation, in a profile update that carries role, and in an import row with a non-empty role — blank role cells import as viewer, the role column carries role keys that the page maps from localized labels, and any assigning row without the permission fails the whole request with 403), assignable roles are bounded to viewer/operator/analyst and accounts currently holding admin or lead cannot be re-roled through that path either (so a delegate can neither promote to nor demote from those roles), accounts currently holding admin or lead can only be disabled, enabled, deleted or edited by an administrator-role caller (a delegate holding system.user.update / system.user.delete gets 403, single and batch requests alike), changing your own role is refused for every identity including admin, a holder of system.role.update can neither edit nor reset the role they belong to and can only grant permission keys they hold themselves, exact device coordinates require ops.device.coordinate.view (otherwise the API coarsens longitude and latitude to a 0.1° grid and marks them coarse while serializing), role-permission updates must be a duplicate-free string array of leaf keys with the admin role compared as an exact set, import requests are validated as a whole against row/field/length/text/body budgets before any write, and state-changing routes write an audit log (login/logout, user create/update/disable/delete, role assign/update/reset, import, alarm handling, ticket assign/start/return/close/accept/rework, schedule create/update/delete and demo-clock switches) — while POST /api/v1/demo/reset and POST /api/v1/demo/scenario are deliberately exempt because they clear the store and would erase a log written there. Those checks make the frontend/backend contract demonstrable and testable; they are not a security control, and a real backend must implement every one of them itself.
The demo is off by default. Real production builds require a same-origin UMI_APP_API_BASE and fail otherwise; GitHub Pages hosts the public demo only. Real mode uses cookie sessions, removes the persistent bearer entry, forbids cross-origin API addresses and per-request API overrides, and normalizes login redirects to stay within this app. Frontend permission display never substitutes for server-side authorization.
Vercel, nginx and Cloudflare Pages (public/_headers) enforce CSP; production HTML carries a meta CSP. GitHub Pages cannot serve frame-ancestors/X-Frame-Options headers, so it must not host a real admin console that needs anti-framing protection. Cesium's Knockout controls require unsafe-eval, antd styles require inline styles; rich text must keep flowing through DOMPurify, PDF must keep eval/scripts disabled, and uploads and URL inputs must pass through their respective boundaries. script-src is narrowed to the single loader host https://webapi.amap.com — derived from the installed @pansy/amap-api-loader source and bound to the allowlist by scripts/csp-amap-pinning.test.ts — so no wildcard script trust remains; img-src and connect-src still allow https://*.amap.com / https://*.autonavi.com because map tiles and data services are image/XHR loads, not script sources. Without configured map keys no third-party shared credentials are used; unreviewed analytics integrations fail the build.
The Vercel entry is fail-closed for /api/*: an explicit /api/(.*) rewrite runs before the SPA fallback, and the function in api/ returns 503 JSON when API_ORIGIN is unset, 404 JSON for anything outside a literal /api/v1/* path, and only then proxies the same-origin API. A misconfigured or absent backend therefore never answers an API request with SPA HTML. Runtime cost of that guarantee: all /api traffic passes through a serverless function — cold starts, a request-body limit of roughly 4.5 MB, an execution-time limit and no streaming proxy — so a high-traffic backend should use a platform gateway instead. nginx (location ~ ^/api(/|$), with the 404 JSON body in the single-source nginx/api-fail-closed.conf) and the GitHub-Pages-equivalent local server implement the same closed-fail behaviour; the routing and the function are covered by local regressions, and no live Vercel deployment is claimed. On Cloudflare Pages, public/_redirects rewrites /api and /api/* to a static JSON body before the SPA fallback. Pages _redirects supports only 3xx redirects and 200 rewrites, so the status there is 200 rather than 404; the body (success: false, errorCode: 404) is what makes the request layer fail closed. No live Cloudflare deployment is claimed either.
The Dockerfile declares no mutable # syntax=docker/dockerfile:1 frontend — it uses only default-frontend instructions, and both base images are pinned by digest; if BuildKit-only syntax is ever required it must be pinned to an exact version or digest, which scripts/dockerfile-frontend.test.ts enforces.
Real mode strips the browser demo backend through build-time aliases (@/demo/adapter, @/demo/token), but it is not demo-free: umi-react-admin:demo-clock:v1 from src/demo/clock.ts still appears in three lazy route chunks (Signature, Tickets, Alarms) because src/utils/now.ts statically imports the demo clock. It is outside the entry chunk and outside the demo backend/generator/scenario/store, so the stripping criteria hold, but a frozen clock anchor left in localStorage on an origin previously used for the demo can make real-mode relative times wrong; adding a third build-time alias for @/demo/clock is the recommended fix.
The local preview server binds to 127.0.0.1 only and rejects directory traversal, escaping symlinks and abnormal encodings. It is for local verification; production should use an HTTPS static entry. nginx listens on intranet 8080 with TLS terminated at the deployment entry; HSTS only takes effect once the browser has seen an HTTPS response.
There is no backend implementation today; the following must be delivered server-side when integrating — frontend tests cannot substitute:
- Session cookies use
Secure,HttpOnly, an appropriateSameSite, a restricted Path, and avoid a broad Domain; login re-issues the session, logout invalidates it server-side. - Every business endpoint independently verifies identity, role, object ownership and data scope; client-supplied roles or demo tokens are never accepted. Role assignment needs its own permission, is bounded to the roles a delegating operator may grant (and to target accounts whose current role is in that range), and must refuse self-escalation, including editing one's own role's permissions or granting permissions the editor does not hold; user-management permissions are scoped the same way, so a delegate cannot disable, delete or edit accounts holding roles it could not assign; exact coordinates need a data-level permission and must be coarsened or withheld server-side; permission-list updates must validate keys as a set (duplicates and non-leaf keys rejected, the admin role locked to the full set); import endpoints must bound the request before writing anything; privileged mutations must be written to an audit log that the actor cannot erase.
- State changes use POST/PUT/PATCH/DELETE, check a trusted Origin plus the frontend-sent
X-Requested-With: XMLHttpRequest, and reject cross-origin CORS/simple requests; GET/HEAD must not mutate state. Any additional cross-origin scenario needs a real CSRF scheme. - Login/logout/currentUser responses carry
Cache-Control: no-store; errors use proper HTTP statuses, and endpoints must never return SPA HTML. Credentials go only to the fixed same-origin API; the server owns rate limiting, credential storage and session lifetime. - The HTTPS entry provides certificates, a same-origin API proxy, CSP and other security headers, and real responses plus permission regressions are verified. The template itself declares no verified online backend or TLS environment.
XLSX is checked for ZIP structure, actual decompressed size, XML, coordinates, merges and named ranges before reaching ExcelJS; file/row/column/field budgets apply and parsing runs in a terminable worker with a 10-second cap. PDF has file/page/visible-window/canvas/image budgets and cancels stale tasks. These budgets bound what the template accepts as input; they do not prove browser-native decoders are defect-free against arbitrary malformed files.
PDF decoded-resource preflight (F13). Before a file reaches react-pdf/PDF.js, the PDF preview parses its container with a dependency-free preflight (src/pages/Document/Pdf/pdfPreflight.ts) and rejects it when any budget is exceeded: 50 000 objects, 200 000 parse tokens, parse depth 64, 10 000 streams, 32 MiB per decoded stream, 128 MiB decoded across all streams, 256 object streams (8 MiB each, 32 MiB total), 4 000 page-tree nodes, page-tree depth 64, 1 000 pages including a declared /Count, 128 fonts, 32 MiB of embedded font data, 5 000 annotations (1 000 per page), 500 000 text-showing operators (50 000 per content stream), 8 MiB per content stream, and a 3-second scan deadline. Decoding is counted incrementally and discarded, so a decompression bomb aborts at the budget instead of materialising its output; timeout, abort and unmount cancel the scan, release the PDF document and revoke generated object URLs, and stale load/render callbacks are ignored. The rendered page also switches off the annotation and text layers (renderAnnotationLayer={false}, renderTextLayer={false}), so react-pdf no longer calls getAnnotations()/getTextContent() for visible pages.
Honest residual limits: PDF.js exposes no decoded-resource accounting API, so this preflight is a best-effort container-level bound and not a safety proof for arbitrary malformed PDFs. Only FlateDecode is decoded; streams whose first decoder is LZWDecode/RunLengthDecode, and ASCII85/ASCIIHex-armored chains that hide a later Flate layer, are counted with a conservative 64x estimate instead of a measurement. PNG/TIFF predictors are not applied, so predicted streams are accounted approximately (same order of magnitude). Image coders (DCTDecode/JPXDecode/CCITTFaxDecode/JBIG2Decode) are counted at their compressed size — their expanded pixel buffers are not measured here and remain bounded only by PDF.js maxImageSize and the canvas/window budgets above. Every physical definition of an object number is budgeted — incremental updates and decoy duplicates both repeat object numbers, and PDF.js picks whichever definition the xref points to — so all stream copies are decoded and every page-tree node definition passes the page-tree budgets (references resolve to the last definition, matching PDF.js xref reconstruction). Encrypted documents (/Encrypt in a trailer or an xref-stream dictionary) are rejected outright: their streams are ciphertext and cannot be accounted, while PDF.js would decrypt them with an empty user password. A file whose catalog or page tree cannot be located is rejected as well (fail closed), after all of its streams have still been accounted; the preflight remains a container parser rather than a full PDF validator. A stream's /Length may be resolved by a bounded endstream search when it is an indirect reference, and the scan runs synchronously on the main thread, so a document needing more than the 3-second deadline is rejected rather than accepted. None of this demonstrates that browser-native decoders are defect-free on hostile input.
Historical advisories are no longer recorded as accepted-without-fix. Real upgrades, removals or source patches are tracked in THIRD-PARTY-NOTICES.md. Raw pnpm audit may still flag locally patched packages by version number; the raw output is kept, and pnpm audit:security must additionally verify source hashes of every installed instance plus real attack regressions — new advisories and unproven instances fail. Gates must never be passed by ignoring advisories, faking version numbers, or assessing only unreachable paths.