Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion convex/authEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ type MailEnvironment = {

export function normalizeAuthEmail(value: unknown): string {
if (typeof value !== "string") throw new Error("invalid_email");
const email = value.trim().toLowerCase();
if (value.length > 512) throw new Error("invalid_email");
const email = value.normalize("NFKC").trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error("invalid_email");
}
Expand Down
104 changes: 104 additions & 0 deletions docs/security/EXCELJS_DEPENDENCY_HARDENING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# ExcelJS dependency hardening

## Decision

Keep the public `exceljs@4.4.0` API and harden its two ZIP dependency paths
without changing the workbook contract:

- `archiver` -> `@excel.js/archiver@0.0.5`
- `unzipper` -> official `unzipper@0.10.14`
- `unzipper -> fstream` -> NodeRoom's local
`fstream@1.0.12+noderoom.fail-closed.1` compatibility package

The archive writer is the narrow, ExcelJS-compatible package published from
[`excel-js/excel-js`](https://github.com/excel-js/excel-js). The reader stays on
the exact parser version ExcelJS 4.4.0 already used because its entry ordering is
part of ExcelJS's shared-string streaming behavior. ExcelJS only invokes
`unzipper.Parse({ forceStream: true })`; it never invokes `Extract` or `Open`,
which are the `fstream`-backed extraction APIs. The local compatibility package
therefore throws on every `fstream` operation. If a future code path attempts
extraction, it fails closed instead of silently reintroducing filesystem writes.

The direct `unzipper` and local `fstream` dependency entries are deterministic
anchors for npm's `$dependency` override syntax. The package lock records the
exact writer tarball integrity and the explicitly NodeRoom-owned build identity
for the local compatibility boundary. Build metadata keeps the
identity visibly local while satisfying `unzipper`'s `^1.0.12` compatibility
range; a prerelease suffix would fall outside that range and make `npm ci`
resolve the vulnerable upstream package again.

## Root cause

The July 2026 `brace-expansion` denial-of-service advisory affected every
release through `5.0.7`. ExcelJS 4.4.0 reached vulnerable legacy releases over
two independent production paths:

```text
exceljs -> archiver -> archiver-utils/readdir-glob -> glob/minimatch -> brace-expansion
exceljs -> unzipper -> fstream -> rimraf -> glob/minimatch -> brace-expansion
```

The same release gate also found three critical Auth.js advisories because the
application pinned `@auth/core@0.41.1`; the patched compatible version is
`0.41.3`. NodeRoom's custom email-provider normalizer also validated before
Unicode canonicalization, so it now applies bounded NFKC normalization before
checking the one-`@` address shape. This closes the application-specific form of
the Auth.js homoglyph advisory instead of relying on the dependency bump alone.

The clean-lock verification also surfaced newer high-severity advisories in the
development graph. The lock now selects `fast-uri@3.1.4`,
`postcss@8.5.24`, and its compatible `nanoid@3.3.16` transitive without changing
the declared dependency ranges.

## Alternatives rejected

- `npm audit fix --force` proposed downgrading ExcelJS to 4.1.1 and did not
provide a compatibility argument.
- Overriding only `brace-expansion` is unsafe because legacy Minimatch expects a
callable CommonJS export, while patched brace-expansion 5 exposes `expand` as
a named export.
- Overriding ExcelJS to `archiver@8` is not compatible: ExcelJS expects a
callable CommonJS factory and passes its legacy `StreamBuf`, which Archiver 8
rejects.
- Upgrading to `unzipper@0.12` can reorder ZIP entries in the streaming reader
and break shared-string caching.
- The initially evaluated `@excel.js/unzipper@0.0.2` preserves the API shape but
provides no reliability advantage. Stress diagnostics reproduced ExcelJS
4.4's existing short-archive shared-string timing race with the original,
scoped, and current `0.12` parser implementations. NodeRoom's application
paths use `Workbook.xlsx.load/readFile`, not `WorkbookReader`, so the gate
exercises the real application reader concurrently and retains one sustained
`WorkbookReader` round trip to protect the dependency contract.
- Replacing `fstream` with an implementation that extracts files would preserve
an unused attack surface. The selected boundary makes the supported parse-only
contract explicit and rejects extraction.

## Verification

Run:

```powershell
npm ci
npm audit --audit-level=moderate
npm audit --omit=dev --audit-level=moderate
npm test -- --run tests/authEmailVerification.test.ts
npm test -- --run tests/exceljsDependencyCompatibility.test.ts tests/artifactXlsxExport.test.ts tests/spreadsheetParser.test.ts
npm run floor
npm run prod:gate
```

`tests/exceljsDependencyCompatibility.test.ts` protects the non-obvious
compatibility boundary with a 1,024-row streaming write/read, 16 waves of four
concurrent writers loaded through NodeRoom's application reader, truncated-input
failure, exact package-resolution checks, and a fail-closed extraction
assertion.

## Primary advisories

- [Auth.js malformed Bearer handling](https://github.com/advisories/GHSA-xmf8-cvqr-rfgj)
- [Auth.js email normalization](https://github.com/advisories/GHSA-7rqj-j65f-68wh)
- [Auth.js provider-bound OAuth cookies](https://github.com/advisories/GHSA-x445-f3h2-j279)
- [brace-expansion denial of service](https://github.com/advisories/GHSA-mh99-v99m-4gvg)
- [fast-uri host confusion](https://github.com/advisories/GHSA-v2hh-gcrm-f6hx)
- [fast-uri IDN canonicalization](https://github.com/advisories/GHSA-4c8g-83qw-93j6)
- [PostCSS source-map path traversal](https://github.com/advisories/GHSA-r28c-9q8g-f849)
14 changes: 14 additions & 0 deletions evidence/auth-email-canonicalization/after.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Observable: email-provider identifier canonicalization after remediation
Command: npm test -- --run tests/authEmailVerification.test.ts
Exit: 0

Test Files: 1 passed (1)
Tests: 4 passed (4)

The scenario now proves:
- a single fullwidth @ canonicalizes to the intended ASCII address;
- a second separator exposed by NFKC is rejected;
- malformed input is rejected;
- oversized input is rejected before Unicode normalization;
- missing mail transport still fails closed; and
- the configured transport receives only the normalized address and challenge.
14 changes: 14 additions & 0 deletions evidence/auth-email-canonicalization/before.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Observable: email-provider identifier canonicalization before the @auth/core 0.41.3 remediation is complete
Command: npm test -- --run tests/authEmailVerification.test.ts
Exit: 1

Test Files: 1 failed (1)
Tests: 1 failed | 3 passed (4)

Failed scenario:
normalizeAuthEmail(" Person@Example.COM ") should canonicalize the fullwidth
separator before validation, but the implementation throws "invalid_email".

The same pre-fix implementation validates the raw string before Unicode
normalization, so a second fullwidth separator can be introduced downstream:
"victim@example.com@attacker.example".
58 changes: 58 additions & 0 deletions evidence/npm-audit-remediation/after.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
Observable: production dependency audit after remediation
Base commit: b800709408a3a33b672f239fdbf7d5f348b6d282
Command: npm audit --omit=dev --audit-level=moderate
Exit: 0

found 0 vulnerabilities

The stricter all-dependency command also passes:
Command: npm audit --audit-level=moderate
Exit: 0

found 0 vulnerabilities

Exact installed production paths:
noderoom@0.1.1
+-- @auth/core@0.41.3
+-- @convex-dev/auth@0.0.94
| `-- @auth/core@0.41.3 deduped
+-- exceljs@4.4.0 overridden
| +-- archiver@npm:@excel.js/archiver@0.0.5 overridden
| `-- unzipper@0.10.14 deduped
+-- fstream@1.0.12+noderoom.fail-closed.1 -> vendor/exceljs-security/fstream
`-- unzipper@0.10.14 overridden
`-- fstream@1.0.12+noderoom.fail-closed.1 deduped -> vendor/exceljs-security/fstream

Compatibility proof:
Command: npm test -- --run tests/authEmailVerification.test.ts tests/exceljsDependencyCompatibility.test.ts tests/artifactXlsxExport.test.ts tests/spreadsheetParser.test.ts
Exit: 0
Test Files: 4 passed
Tests: 19 passed

Repeated compatibility stress:
Command: 12 consecutive runs of tests/exceljsDependencyCompatibility.test.ts
Exit: 0
Runs: 12/12
Scenario per run: 1,024-row stream; 16 waves x 4 concurrent writers; truncated input; exact dependency/fail-closed resolution

Canonical floor:
Command: npm run floor
Exit: 0
Test Files: 373 passed
Tests: 2571 passed

Full production gate:
Command: npm run prod:gate
Exit: 0
Security gate: pass (source and dist)
Design audit: pass (existing guidance-only token warnings retained)
QA matrix: current
Content fluency: pass
Proof staleness: pass
Fresh-room proofs: FR-010 and FR-020 pass
SLO gate: pass (8/8 completed, 0 errors, p95 18ms)
Root and Convex typechecks: pass
Test Files: 373 passed
Tests: 2571 passed
Product-memory Playwright: pass
Production build: pass
75 changes: 75 additions & 0 deletions evidence/npm-audit-remediation/before.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
Observable: production dependency audit on clean origin/main before remediation
Commit: b800709408a3a33b672f239fdbf7d5f348b6d282
Command: npm audit --omit=dev --audit-level=moderate
Exit: 1

# npm audit report

@auth/core <=0.41.2
Severity: critical
Auth.js: getToken() throws an uncaught exception on malformed Bearer authorization headers - https://github.com/advisories/GHSA-xmf8-cvqr-rfgj
Auth.js: Email normalizer validates the address before Unicode normalization, allowing a homoglyph @ bypass - https://github.com/advisories/GHSA-7rqj-j65f-68wh
Auth.js: OAuth state, nonce, and PKCE check cookies are not bound to the provider that created them - https://github.com/advisories/GHSA-x445-f3h2-j279
fix available via `npm audit fix --force`
Will install @auth/core@0.41.3, which is outside the stated dependency range
node_modules/@auth/core

brace-expansion <=5.0.7
Severity: high
brace-expansion: DoS via unbounded expansion length causing an out-of-memory process crash - https://github.com/advisories/GHSA-mh99-v99m-4gvg
fix available via `npm audit fix --force`
Will install exceljs@4.1.1, which is a breaking change
node_modules/minimatch/node_modules/brace-expansion
node_modules/readdir-glob/node_modules/brace-expansion
minimatch 2.0.0 - 10.0.2
Depends on vulnerable versions of brace-expansion
node_modules/minimatch
node_modules/readdir-glob/node_modules/minimatch
glob 4.3.0 - 10.5.0
Depends on vulnerable versions of minimatch
node_modules/glob
archiver-utils >=0.2.0
Depends on vulnerable versions of glob
node_modules/archiver-utils
node_modules/zip-stream/node_modules/archiver-utils
archiver 0.20.0 - 7.0.1
Depends on vulnerable versions of archiver-utils
Depends on vulnerable versions of readdir-glob
Depends on vulnerable versions of zip-stream
node_modules/archiver
exceljs >=4.2.0
Depends on vulnerable versions of archiver
node_modules/exceljs
zip-stream 0.8.0 - 6.0.1
Depends on vulnerable versions of archiver-utils
node_modules/zip-stream
rimraf 2.3.0 - 3.0.2 || 4.2.0 - 5.0.10
Depends on vulnerable versions of glob
node_modules/rimraf
readdir-glob <=2.0.3
Depends on vulnerable versions of minimatch
node_modules/readdir-glob

10 vulnerabilities (9 high, 1 critical)

Exact installed production paths:
noderoom@0.1.1
+-- @auth/core@0.41.1
+-- @convex-dev/auth@0.0.94
| `-- @auth/core@0.41.1 deduped
`-- exceljs@4.4.0 overridden
+-- archiver@5.3.2
| +-- archiver-utils@2.1.0
| | `-- glob@7.2.3
| | `-- minimatch@3.1.5
| | `-- brace-expansion@1.1.16 overridden
| +-- readdir-glob@1.1.3
| | `-- minimatch@5.1.9
| | `-- brace-expansion@2.1.2
| `-- zip-stream@4.1.1
| `-- archiver-utils@3.0.4
| `-- glob@7.2.3 deduped
`-- unzipper@0.10.14
`-- fstream@1.0.12
`-- rimraf@2.7.1
`-- glob@7.2.3 deduped
Loading
Loading