Skip to content

Commit 2ded471

Browse files
SECURITY.md: record the four-axis audit; bump counts to 386 tests
Audit record — what was found, fixed (9 findings, all with regression tests), and accepted-as-risk (activitypub Phase-2 signature verification, DNS-rebind TOCTOU shared with corsproxy, s3/relay perf deferrals, per-resource size cap, pluginDir read-modify-write TOCTOU) — plus the long 'checked and found solid' list. Linked from README/AGENT; STRATEGY gap #4 (trust signals) updated to note the audit is done.
1 parent e54aba2 commit 2ded471

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

AGENT.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Out-of-tree plugins for [JavaScript Solid
1111
Server](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer),
1212
built on its **#206 loader** (`createServer({ plugins })`, JSS ≥ 0.0.215).
1313
It is an *experiment*: prove the plugin api by using it, and treat every
14-
wall you hit as a finding, not a blocker. **33 plugins, 374 tests today.**
14+
wall you hit as a finding, not a blocker. **33 plugins, 386 tests today.**
1515

1616
### The one rule that makes the experiment valid
1717

@@ -179,7 +179,9 @@ Full ranking in `NOTES.md`. The ones you'll hit:
179179
180180
## Current state
181181
182-
33 plugins (7 ports + 26 features), 374 tests, all green (`npm test`).
182+
33 plugins (7 ports + 26 features), 386 tests, all green (`npm test`).
183+
A four-axis security review ([SECURITY.md](./SECURITY.md)) hardened the
184+
inbound-federation and query surfaces; the WAC-deferral pattern held.
183185
`compose.test.js` runs every one on a single server from pure config. Two
184186
core PRs (#590 `api.mountApp`, #591 `/idp/refresh`) sit upstream, unmerged,
185187
for the maintainer's call. Everything else lives here, by design.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,15 @@ README and NOTES.md, ship the closest honest approximation.
9292
| `shortlink/` || link shortener for pod URLs (deliberately not an open redirector) |
9393
| `admin/` || **the capstone**: wp-admin-style operator home; each missing pillar names its seam |
9494

95-
**374 tests, all green** (`npm test`), including `compose.test.js` — all
95+
**386 tests, all green** (`npm test`), including `compose.test.js` — all
9696
thirty-three plugins on one server from pure config, pods + WAC intact
9797
beside them. Findings consolidated in [NOTES.md](./NOTES.md); the full
9898
plugin-tagged backlog triaged in [ISSUES.md](./ISSUES.md); the build guide
9999
is [AGENT.md](./AGENT.md); the maintainer-facing summary — what to add
100100
to the api next, ranked, with evidence — is [REPORT.md](./REPORT.md);
101101
where this sits in the ecosystem and the realistic play is
102-
[STRATEGY.md](./STRATEGY.md).
102+
[STRATEGY.md](./STRATEGY.md); the four-axis security review and its
103+
outcomes is [SECURITY.md](./SECURITY.md).
103104

104105
```
105106
<name>/plugin.js the port <name>/test.js real-JSS tests <name>/README.md findings

SECURITY.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# SECURITY.md — audit record
2+
3+
A four-axis adversarial review of all 33 plugins (2026-07), and what it
4+
found. Axes: authentication/authorization, SSRF/path/parser input,
5+
resource-safety/DoS/lifecycle, and correctness/protocol conformance. Each
6+
reviewer read every `plugin.js` and cross-checked findings against
7+
[NOTES.md](./NOTES.md)/[REPORT.md](./REPORT.md) so documented limitations
8+
were not reported as bugs.
9+
10+
**Headline:** the fleet held up well. The dominant pattern —
11+
resolve identity with `api.auth.getAgent`, forward the client's
12+
`Authorization` on every loopback call, and let the host's WAC decide —
13+
is applied correctly and consistently across the whole data plane. Real
14+
defects clustered exactly where a plugin *invented its own* behavior
15+
instead of deferring to WAC (activitypub's unauthenticated federation
16+
surface, sparql's user-supplied regex). All confirmed findings are
17+
fixed, each with a regression test; the suite went 374 → 386 tests.
18+
19+
## Fixed
20+
21+
| Severity | Plugin(s) | Finding | Fix | Commit |
22+
|---|---|---|---|---|
23+
| High | activitypub | Unauthenticated inbox fetched an attacker-controlled `actor`/`inbox` URL with `redirect: follow` and no gate → SSRF pivot (e.g. `169.254.169.254`); unbounded per-user state growth; non-atomic write | `gatedFetch` (corsproxy's private-IP guard + DNS `resolvesToPublic`, fail-closed, manual per-hop redirect re-validation); inbox trimmed to `maxInbox`, followers deduped; atomic state write; bodies consumed; outbox walk capped | `03e4db5` |
24+
| High | sparql | `FILTER(REGEX(...))` compiled a user pattern and ran it on pod-plantable strings with no bound → ReDoS event-loop hang | Reject patterns over `maxRegexLength` (512) and catastrophic nested-quantifier shapes; truncate tested input to `maxRegexInput` (10k) | `f1255a7` |
25+
| High | caldav, carddav | free-busy/query/multiget/PROPFIND walked every member with a full-body buffer each, uncapped → DoS amplification | `config.maxResources` cap (default 10k) truncates the member/href list before the walk; oversized → truncated-but-valid 207 | `642dec2` |
26+
| Med | otp, capability, shortlink | Direct `writeFileSync` of the whole table → a crash mid-write truncates it; loader swallows the parse error into `{}` (capability: a revoked token silently revives) | Atomic write (unique temp file + `renameSync`) | `ca52373` |
27+
| Med | jmap | `Email/set` create omitted server-set props (`blobId`/`threadId`/`size`) required by RFC 8621 §4.6 | Echo them in the create response (blobId = sha256 of stored JSON, threadId = id, size = byte length) | `e54aba2` |
28+
| Low-Med | gitscratch | Auth gate required *an* agent but never checked it against the repo creator → any authed user could push to another's repo | Opt-in `config.owned` binds a repo to its first materializer (default stays shared) | `a8847a3` |
29+
| Low | rss | Atom output missing the RFC 4287 §4.1.1-required `<author>` | Feed-level `<author>` from `config.author`/title | `e54aba2` |
30+
| Low | remotestorage | Forwarded host `content-length` over a fetch-decoded stream (latent framing bug) | Drop it, matching the webdav sibling | `e54aba2` |
31+
| Low | terminal | Query-token compare early-exited on length mismatch (timing side-channel on token length) | Constant-time sha256 + `timingSafeEqual` | `e54aba2` |
32+
33+
## Accepted risk / deferred (documented, not fixed)
34+
35+
- **activitypub inbound signature verification is still Phase 2.** The
36+
inbox remains unauthenticated by design; the SSRF gate + state caps are
37+
the mitigation. Full HTTP-Signature verification is a larger feature,
38+
tracked as a known limitation in activitypub/README.md.
39+
- **DNS-rebinding TOCTOU** in `gatedFetch` (activitypub) and `corsproxy/`:
40+
the address is validated at `dns.lookup` time, but `fetch` resolves
41+
again independently, so a hostname that flips between the two resolutions
42+
can slip past. Same residual both places; closing it needs pinning the
43+
resolved IP into the connection (no clean dep-free seam today).
44+
- **s3 ListObjectsV2 fetches every object to compute its md5 ETag**
45+
(`s3/`): the crawl is capped (`WALK_MAX_RESOURCES`) and cycle-safe, but a
46+
single list of large objects buffers each in turn. Performance, not a
47+
vulnerability; a write-time metadata cache (the `api.events` seam) would
48+
fix it properly.
49+
- **relay rewrites its whole event file per stored EVENT** (`relay/`):
50+
blocking O(N) I/O on the event loop under sustained traffic (per-socket
51+
rate-limited to 60/min). Bounded by `maxEvents`; an append-only log is
52+
the real fix. Performance, deferred.
53+
- **No per-resource body-size ceiling** on buffered loopback reads (rss,
54+
backup, s3, caldav/carddav): one-at-a-time bounds it to the largest
55+
single resource, but a multi-GB pod file could OOM. `corsproxy/` is the
56+
counter-example (streamed with `maxBodyBytes`). A shared capped-read
57+
helper would generalize the fix; deferred.
58+
- **Read-modify-write TOCTOU** in the pluginDir JSON writers (otp,
59+
capability, shortlink, activitypub): the *write* is now atomic, but two
60+
concurrent request handlers can still interleave read→modify→write and
61+
lose one update. Distinct from the atomicity fix above and from core
62+
[#600](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/600)
63+
(the host's own conditional-write non-atomicity). Low impact at scratch
64+
scale; a per-key lock is the fix.
65+
66+
## What was checked and found solid
67+
68+
- **WAC deferral**: every data-plane plugin forwards the caller's
69+
`Authorization` on loopback and lets WAC decide; none forward it
70+
off-host. `corsproxy/` strips pod credentials from upstream and drops
71+
them on cross-origin redirect. Liveness probes (metrics/dashboard/admin)
72+
deliberately send no auth and see only public responses.
73+
- **Token/secret handling**: capability, otp, s3, metrics all verify the
74+
signature before trusting the payload, compare with `timingSafeEqual`
75+
over equal-length buffers, and gate JSON parse behind the MAC. No
76+
`Math.random` in any security path; secrets from `crypto.randomBytes`.
77+
- **Traversal / containment**: webfinger, didweb, nip05 reject `..`/dot
78+
segments before `path.join`; admin's podsRoot walk is realpath-contained
79+
and skips symlinks; the DAV/s3/backup/remotestorage path handlers reject
80+
traversal and rely on host WAC over loopback.
81+
- **Parsers**: the backup ustar writer, oembed image-dimension parsers,
82+
caldav iCal RRULE expansion (capped), and the sparql tokenizer are
83+
bounds-checked and always advance (no over-read, no infinite loop); s3
84+
and oembed build XML with escaping and parse no untrusted XML (no XXE).
85+
- **XSS**: oembed refuses rich HTML and escapes XML; dashboard and admin
86+
inject values only via client-side `.textContent` (config via
87+
`JSON.stringify` into a script tag), so pod-derived data never lands as
88+
markup.
89+
- **Lifecycle**: the WebSocket plugins (relay, webrtc, terminal, tunnel,
90+
notifications) track sockets and clean up on close/error with a working
91+
`deactivate()`; timers/watchers (metrics, gitscratch, search,
92+
notifications) are cleared on teardown.
93+
94+
## Reporting
95+
96+
This is an experimental research repo, not a production deployment. If you
97+
find a security issue, open an issue on the repo (or, for the core server,
98+
on JavaScriptSolidServer/JavaScriptSolidServer). None of these plugins
99+
should be run on a public deployment without the operator reviewing the
100+
accepted-risk items above.

STRATEGY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ square can be an opportunity or a kill zone; this one has been both.
9898
3. **Identity UX** — WebID/bearer beats passwords technically and loses
9999
at onboarding every time.
100100
4. **Trust signals** — 0.0.x versioning; no third-party-plugin security
101-
story (and plugins can intercept each other today — NOTES.md #9).
101+
story (and plugins can intercept each other today — NOTES.md #9). A
102+
first four-axis internal audit is done ([SECURITY.md](./SECURITY.md))
103+
and the WAC-deferral pattern held, but third-party-plugin sandboxing
104+
and signing remain open.
102105
5. **Bodies** — one maintainer + agents vs. twenty-year communities.
103106
(But see "what changed", point 3.)
104107

0 commit comments

Comments
 (0)