Please do not open public issues for security problems. Use a GitHub private security advisory on this repository instead. We acknowledge reports within 72 hours.
A DeFi frontend is a high-value supply-chain target. If an attacker gets code into the bundle, they can rewrite transaction prompts and drain connected wallets. The attacks we defend against:
| # | Threat | Example |
|---|---|---|
| T1 | A known-vulnerable dependency ships to users | A prototype-pollution or ReDoS advisory in a transitive package |
| T2 | A compromised maintainer publishes a malicious version | The event-stream / ua-parser-js / @ledgerhq/connect-kit incidents |
| T3 | A malicious install script runs on a developer or CI machine | Token theft or a backdoored build from postinstall |
| T4 | Lockfile injection: a PR swaps a resolved URL or integrity hash | resolved points at an attacker registry and the diff is easy to overlook |
| T5 | Dependency confusion or typosquatting | A package name that resolves from an unexpected host |
| T6 | A license incompatible with how we distribute | GPL code in the shipped bundle, or a package with no license |
| Control | Where | Mitigates | Purpose |
|---|---|---|---|
| Audit gate | security.yml → audit, scripts/security/audit-gate.mjs |
T1 | Fails on high or critical advisories from npm audit. Waivers go in security/audit-exceptions.json. Each waiver needs a GHSA id, a reason, an approver, and an expiry date no more than 90 days out. Expired waivers stop working and CI fails. The job also runs daily, so new advisories fail even when the code hasn't changed. |
| OSV-Scanner | security.yml → osv |
T1 | A second, independent advisory database (OSV aggregates GHSA and ecosystem feeds). Ignores go in osv-scanner.toml with a reason and an expiry. |
| Minimum release age of 3 days | renovate.json |
T2 | Most malicious releases are found and unpublished within hours. Waiting 3 days before proposing an update removes most of that window. Security fixes skip the wait. |
| Reviewed updates; auto-merge only for dev-dependency patches | renovate.json |
T2 | Runtime dependencies, and anything wallet-, signing- or chain-related (security-critical label), always get a human review. Only devDependencies patch updates auto-merge, and only after required CI passes. |
ignore-scripts=true |
.npmrc |
T3 | npm never runs dependency lifecycle scripts implicitly. |
| Install-script allowlist | package.json → lavamoat.allowScripts, npm run setup |
T3 | @lavamoat/allow-scripts fails CI if any dependency has install scripts that aren't in the allowlist. It then runs only the scripts marked true. @lavamoat/preinstall-always-fail makes installs break loudly on any machine where ignore-scripts got turned off. |
npm ci only |
security.yml → lockfile |
T2, T4 | Installs exactly what the lockfile says. CI rejects npm install in workflows and the Dockerfile, and fails if package.json and the lockfile disagree. |
| lockfile-lint | security.yml → lockfile, npm run security:lockfile |
T4, T5 | Every resolved URL must be on registry.npmjs.org over HTTPS. Every entry must have an integrity hash and a package name that matches its URL. |
| Lockfile-only change flag | security.yml → lockfile |
T4 | A PR that changes package-lock.json without changing package.json fails. A maintainer can pass it by adding the lockfile-only-change label after reviewing the diff. Renovate's monthly lock maintenance PRs carry this label. |
| Exact pins | .npmrc save-exact, Renovate rangeStrategy: pin |
T2 | New dependencies are pinned exactly, so a version only changes through a reviewed PR. |
| License gate | security.yml → licenses, scripts/security/check-licenses.mjs, security/license-policy.json |
T6 | Evaluates each package's SPDX expression against an allowlist of permissive licenses. It rejects strong copyleft (GPL, AGPL, SSPL) and unknown or missing licenses. Reviewed exceptions expire. |
All current lifecycle scripts are denied (false). Each package has a
prebuilt binary or a JS fallback:
| Package (path) | Why it has a script | Decision |
|---|---|---|
…>@near-js/crypto>secp256k1 |
Native build via node-gyp-build | false. Falls back to the pure-JS elliptic. |
…>ws>bufferutil, …>ws>utf-8-validate |
Optional native speed-ups | false. ws works without them. |
…>jest-haste-map>@parcel/watcher |
Builds from source only if no prebuild exists | false. Uses the prebuilt optional packages. |
…>eslint-import-resolver-typescript>unrs-resolver |
Fixes up napi optional binaries | false. Optional platform packages are installed normally. |
tailwindcss>chokidar>fsevents |
macOS-only native watcher | false. Prebuilt. |
@creit.tech/stellar-wallets-kit>@reown/appkit |
postinstall | false. Not needed at runtime. |
To add a package that genuinely needs its script (for example esbuild,
which downloads a platform binary):
npm installit (scripts won't run).- Run
npx allow-scripts auto. This adds the package's canonical path tolavamoat.allowScriptsasfalse. - Read the package's
installorpostinstallscript. If it's required, set the entry totrueand explain why in the PR. - Run
npm run setupto execute the allowlisted scripts.
npm ci # installs, runs no scripts
npm run setup # runs only the allowlisted install scripts
npm run security:audit
npm run security:lockfile
npm run security:licensesTo check that the audit gate actually blocks something:
git switch -c demo/vulnerable-dep
npm install --save-exact lodash@4.17.20 # GHSA-35jh-r3h4-6jhm (command injection, HIGH)
node scripts/security/audit-gate.mjs # exits 1: "HIGH lodash GHSA-35jh-r3h4-6jhm …"Opening that branch as a PR shows Security / Dependency audit failing. The
lockfile job also flags the lockfile change. Attach that CI run to the PR
that introduces these controls as gate-failure evidence, then close the demo
PR without merging.
User-generated and backend-provided content is untrusted: proposal markdown, delegate statements, strategy descriptions, backend error strings, and token and asset metadata. There is one place to render each kind:
| Content | Render with |
|---|---|
| Markdown | <SafeMarkdown> (src/components/SafeMarkdown.tsx) |
| Plain strings, including backend errors | safeText() / <SafeText> (src/lib/sanitize.ts) |
| Stellar addresses | <SafeAddress>: homoglyphs and zero-width characters are shown explicitly |
| Display names, asset codes | <SafeName>: bidi isolation plus a mixed-script warning |
| URLs from data | safeUrl(): absolute http(s) only, no credentials |
SANITIZE_SCHEMA (src/lib/sanitize.ts, SANITIZE_SCHEMA_VERSION) is the
only sanitize schema. It allows no raw HTML, no style, class, id or
on* attributes, and only http(s) and mailto links. Links get
rel="noopener noreferrer nofollow". Images render only from
TRUSTED_IMAGE_ORIGINS (the image proxy); otherwise they're replaced by
their alt text. Any change to the schema bumps the version and needs a
security review.
ESLint enforces the following (.eslintrc.json):
react/no-danger, allowed only in src/components/TrustedHtml.tsx, which
accepts only interpolation-free build-time literals; react/jsx-no-script-url
and no-script-url; react/jsx-no-target-blank; bans on innerHTML,
outerHTML, insertAdjacentHTML, createContextualFragment and
document.write; and a ban on importing react-markdown, rehype-sanitize
or rehype-raw outside the sanitize layer.
The corpus in src/test-utils/xssCorpus.ts holds more than 250 vectors: the
OWASP filter-evasion cheat sheet plus markdown-specific payloads. CI runs it
against every renderer in src/components/__tests__/xss.corpus.test.tsx
(Security / XSS corpus) and uploads the JSON results as an artifact.
A Content Security Policy is tracked separately and complements these controls.