A four-axis adversarial review of all 33 plugins (2026-07), and what it
found. Axes: authentication/authorization, SSRF/path/parser input,
resource-safety/DoS/lifecycle, and correctness/protocol conformance. Each
reviewer read every plugin.js and cross-checked findings against
NOTES.md/REPORT.md so documented limitations
were not reported as bugs.
Headline: the fleet held up well. The dominant pattern —
resolve identity with api.auth.getAgent, forward the client's
Authorization on every loopback call, and let the host's WAC decide —
is applied correctly and consistently across the whole data plane. Real
defects clustered exactly where a plugin invented its own behavior
instead of deferring to WAC (activitypub's unauthenticated federation
surface, sparql's user-supplied regex). All confirmed findings are
fixed, each with a regression test; the suite went 374 → 386 tests.
| Severity | Plugin(s) | Finding | Fix | Commit |
|---|---|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Low | rss | Atom output missing the RFC 4287 §4.1.1-required <author> |
Feed-level <author> from config.author/title |
e54aba2 |
| Low | remotestorage | Forwarded host content-length over a fetch-decoded stream (latent framing bug) |
Drop it, matching the webdav sibling | e54aba2 |
| Low | terminal | Query-token compare early-exited on length mismatch (timing side-channel on token length) | Constant-time sha256 + timingSafeEqual |
e54aba2 |
- activitypub inbound signature verification is still Phase 2. The inbox remains unauthenticated by design; the SSRF gate + state caps are the mitigation. Full HTTP-Signature verification is a larger feature, tracked as a known limitation in activitypub/README.md.
- DNS-rebinding TOCTOU in
gatedFetch(activitypub) andcorsproxy/: the address is validated atdns.lookuptime, butfetchresolves again independently, so a hostname that flips between the two resolutions can slip past. Same residual both places; closing it needs pinning the resolved IP into the connection (no clean dep-free seam today). - s3 ListObjectsV2 fetches every object to compute its md5 ETag
(
s3/): the crawl is capped (WALK_MAX_RESOURCES) and cycle-safe, but a single list of large objects buffers each in turn. Performance, not a vulnerability; a write-time metadata cache (theapi.eventsseam) would fix it properly. - relay rewrites its whole event file per stored EVENT (
relay/): blocking O(N) I/O on the event loop under sustained traffic (per-socket rate-limited to 60/min). Bounded bymaxEvents; an append-only log is the real fix. Performance, deferred. - No per-resource body-size ceiling on buffered loopback reads (rss,
backup, s3, caldav/carddav): one-at-a-time bounds it to the largest
single resource, but a multi-GB pod file could OOM.
corsproxy/is the counter-example (streamed withmaxBodyBytes). A shared capped-read helper would generalize the fix; deferred. - Read-modify-write TOCTOU in the pluginDir JSON writers (otp, capability, shortlink, activitypub): the write is now atomic, but two concurrent request handlers can still interleave read→modify→write and lose one update. Distinct from the atomicity fix above and from core #600 (the host's own conditional-write non-atomicity). Low impact at scratch scale; a per-key lock is the fix.
- WAC deferral: every data-plane plugin forwards the caller's
Authorizationon loopback and lets WAC decide; none forward it off-host.corsproxy/strips pod credentials from upstream and drops them on cross-origin redirect. Liveness probes (metrics/dashboard/admin) deliberately send no auth and see only public responses. - Token/secret handling: capability, otp, s3, metrics all verify the
signature before trusting the payload, compare with
timingSafeEqualover equal-length buffers, and gate JSON parse behind the MAC. NoMath.randomin any security path; secrets fromcrypto.randomBytes. - Traversal / containment: webfinger, didweb, nip05 reject
../dot segments beforepath.join; admin's podsRoot walk is realpath-contained and skips symlinks; the DAV/s3/backup/remotestorage path handlers reject traversal and rely on host WAC over loopback. - Parsers: the backup ustar writer, oembed image-dimension parsers, caldav iCal RRULE expansion (capped), and the sparql tokenizer are bounds-checked and always advance (no over-read, no infinite loop); s3 and oembed build XML with escaping and parse no untrusted XML (no XXE).
- XSS: oembed refuses rich HTML and escapes XML; dashboard and admin
inject values only via client-side
.textContent(config viaJSON.stringifyinto a script tag), so pod-derived data never lands as markup. - Lifecycle: the WebSocket plugins (relay, webrtc, terminal, tunnel,
notifications) track sockets and clean up on close/error with a working
deactivate(); timers/watchers (metrics, gitscratch, search, notifications) are cleared on teardown.
This is an experimental research repo, not a production deployment. If you find a security issue, open an issue on the repo (or, for the core server, on JavaScriptSolidServer/JavaScriptSolidServer). None of these plugins should be run on a public deployment without the operator reviewing the accepted-risk items above.