Skip to content

Commit e54aba2

Browse files
jmap/rss/remotestorage/terminal: protocol-conformance fixes
Audit correctness findings: - jmap Email/set create now echoes server-set props per RFC 8621 §4.6 (blobId = sha256 of the stored JSON, threadId = id for degenerate one-message threads, size = byte length) — a real client caches these instead of re-fetching. - rss Atom output now emits the required feed-level <author> (RFC 4287 §4.1.1; W3C Feed Validator rejected it before). RSS 2.0 path untouched. - remotestorage GET no longer forwards the host content-length over a fetch-decoded stream — matches the webdav sibling (let fastify frame). - terminal query-token compare is now constant-time (sha256 + timingSafeEqual), no length early-exit leak.
1 parent 03e4db5 commit e54aba2

9 files changed

Lines changed: 72 additions & 12 deletions

File tree

jmap/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,14 @@ The stored resource is readable as a plain pod document too — `GET
126126
— the same wall as micropub's media endpoint. See Findings #5.
127127
- **No threads** (`Thread/get`), no `Email/import`, no
128128
`EmailSubmission/*` — this stores and organizes mail; it does not send
129-
SMTP.
129+
SMTP. `Email/set` create still returns the RFC 8621 §4.6 server-set
130+
properties (`id`, `blobId`, `threadId`, `size`) so a client can cache them
131+
without a re-fetch, but two are **degenerate and documented**: `threadId`
132+
equals the message id (every message is its own single-message thread,
133+
since there is no `Thread/get`), and `blobId` is a SHA-256 hash of the
134+
stored message JSON (there is no separate blob store — the JSON resource
135+
*is* the blob; see also Findings #5). `size` is the honest byte length of
136+
that stored representation.
130137
- **One mailbox per message** (`maxMailboxesPerEmail: 1`, advertised): a
131138
message is one resource in one container, so JMAP's "email in several
132139
mailboxes at once" doesn't map to LDP containment.

jmap/plugin.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,18 +370,35 @@ export async function activate(api) {
370370
continue;
371371
}
372372
const id = newEmailId();
373-
const email = { id, mailboxIds: { [mb.id]: true }, keywords: {} };
373+
// threadId is degenerate: this server has no Thread/get, so every
374+
// message is its own single-message thread (threadId = the message id).
375+
// Persisting it keeps Email/get consistent with the create response.
376+
const email = { id, threadId: id, mailboxIds: { [mb.id]: true }, keywords: {} };
374377
for (const p of EMAIL_PROPS) if (spec[p] !== undefined) email[p] = spec[p];
375378
email.receivedAt = typeof spec.receivedAt === 'string' ? spec.receivedAt : new Date().toISOString();
376379
email.preview = previewOf(spec);
380+
// The stored representation is exactly what writeEmail PUTs — its byte
381+
// length is `size`, and its content hash is a stable blobId (there is no
382+
// separate blob store; the message JSON IS the blob).
383+
const stored = JSON.stringify(email, null, 2);
377384
const put = await writeEmail(ctx.podPath, mb, id, email, ctx.auth);
378385
if (put.status === 401 || put.status === 403) {
379386
notCreated[cid] = { type: 'forbidden' };
380387
} else if (!(put.ok || put.status === 204)) {
381388
notCreated[cid] = { type: 'serverFail', description: `pod storage rejected the message (${put.status})` };
382389
} else {
383-
// Server-set properties back to the client, per RFC 8620 §5.3.
384-
created[cid] = { id, receivedAt: email.receivedAt, preview: email.preview };
390+
// Echo EVERY server-set property the client didn't supply, per
391+
// RFC 8620 §5.3 / RFC 8621 §4.6 — a client caches these from the
392+
// response instead of re-fetching. blobId/threadId are degenerate but
393+
// stable and honest (see README Findings): no blob store, no threads.
394+
created[cid] = {
395+
id,
396+
blobId: crypto.createHash('sha256').update(stored).digest('base64url').slice(0, 24),
397+
threadId: id,
398+
size: Buffer.byteLength(stored),
399+
receivedAt: email.receivedAt,
400+
preview: email.preview,
401+
};
385402
}
386403
}
387404

jmap/test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ describe('jmap plugin', () => {
182182
assert.ok(args.created?.m1?.id, `not created: ${JSON.stringify(args)}`);
183183
assert.ok(args.created.m1.receivedAt, 'no server-set receivedAt');
184184
assert.match(args.created.m1.preview, /Hello over a pod/);
185+
// RFC 8621 §4.6: the create response echoes every server-set property so
186+
// the client caches them instead of re-fetching (blobId/threadId/size).
187+
assert.ok(args.created.m1.blobId, 'no server-set blobId');
188+
assert.strictEqual(args.created.m1.threadId, args.created.m1.id,
189+
'threadId is the degenerate per-message id (one-message threads)');
190+
assert.ok(Number.isInteger(args.created.m1.size) && args.created.m1.size > 0,
191+
`no server-set size: ${JSON.stringify(args.created.m1)}`);
185192
assert.notStrictEqual(args.oldState, args.newState, 'state string did not change on create');
186193
emailId = args.created.m1.id;
187194

remotestorage/plugin.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,11 @@ export async function activate(api) {
244244
if (relayRefusal(res, reply)) return reply;
245245
if (res.status === 404) return jsonReply(reply, 404, { error: 'Not found' });
246246
cors(reply).code(res.status).header('cache-control', 'no-cache');
247-
for (const h of ['content-type', 'etag', 'content-range', 'content-length']) {
247+
// content-length deliberately NOT forwarded: the body is streamed via
248+
// Readable.fromWeb(res.body) below, already content-decoded by fetch, so
249+
// the host's length can be wrong if it ever content-encodes. Let fastify
250+
// frame the stream itself — matches the webdav/ sibling (webdav GET).
251+
for (const h of ['content-type', 'etag', 'content-range']) {
248252
const v = res.headers.get(h);
249253
if (v) reply.header(h, v);
250254
}

remotestorage/test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ describe('remotestorage plugin', () => {
179179
assert.strictEqual(get.headers.get('etag'), firstEtag,
180180
'GET ETag equals the ETag PUT reported (host ETag passed through both ways)');
181181
assert.match(get.headers.get('content-type') || '', /text\/plain/);
182+
// The streamed GET body is fetch-decoded, so the plugin must NOT forward a
183+
// host content-length that could be wrong under content-encoding — it lets
184+
// fastify frame the stream (matches the webdav/ sibling).
185+
assert.strictEqual(get.headers.get('content-length'), null,
186+
'GET must not forward a host content-length over the fetch-decoded stream');
182187

183188
// The resource is created in the pod's own namespace — visible over LDP.
184189
const ldp = await fetch(`${base}/alice/remotestorage/documents/notes/todo.txt`, {

rss/plugin.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,16 @@ function mapEntry({ url, path }, contentType, bodyText, listingModified) {
165165

166166
// ----------------------------------------------------------- feed rendering
167167

168-
function renderAtom({ feedTitle, feedId, selfUrl, updated, entries }) {
168+
function renderAtom({ feedTitle, feedAuthor, feedId, selfUrl, updated, entries }) {
169169
const parts = [
170170
'<?xml version="1.0" encoding="utf-8"?>',
171171
'<feed xmlns="http://www.w3.org/2005/Atom">',
172172
` <title>${xmlEscape(feedTitle)}</title>`,
173173
` <id>${xmlEscape(feedId)}</id>`,
174174
` <updated>${xmlEscape(updated)}</updated>`,
175+
// RFC 4287 §4.1.1: a feed-level author satisfies the "≥1 author" rule for
176+
// every entry at once (RSS 2.0 has no equivalent requirement).
177+
` <author><name>${xmlEscape(feedAuthor)}</name></author>`,
175178
` <link rel="self" type="application/atom+xml" href="${xmlEscape(selfUrl)}"/>`,
176179
` <link rel="alternate" href="${xmlEscape(feedId)}"/>`,
177180
];
@@ -233,6 +236,9 @@ export async function activate(api) {
233236
const defaultContainer = api.config.defaultContainer || null;
234237
const maxItems = api.config.maxItems ?? DEFAULT_MAX_ITEMS;
235238
const feedTitle = api.config.title || 'Solid pod feed';
239+
// RFC 4287 §4.1.1 requires an atom:feed to carry ≥1 atom:author. Derive the
240+
// name from config.author, else the feed title, else a sensible default.
241+
const feedAuthor = api.config.author || feedTitle || 'Solid pod feed';
236242

237243
/** Which container path this request addresses ('/alice/blog/'), or null. */
238244
function resolveContainer(request) {
@@ -335,6 +341,7 @@ export async function activate(api) {
335341
const updated = result.entries.length ? result.entries[0].updated : new Date().toISOString();
336342
const model = {
337343
feedTitle,
344+
feedAuthor,
338345
feedId: result.containerUrl,
339346
selfUrl: baseUrl + request.raw.url,
340347
updated,

rss/test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ describe('rss feed plugin', () => {
141141
assert.ok(xml.includes(`<id>${base}/alice/blog/</id>`), xml);
142142
assert.ok(xml.includes('<link rel="self"'), xml);
143143
assert.match(xml, /<updated>\d{4}-\d\d-\d\dT/, 'feed needs an <updated>');
144+
// RFC 4287 §4.1.1: an atom:feed must carry ≥1 atom:author. Here it is
145+
// derived from the feed title (no config.author) and XML-escaped.
146+
assert.ok(xml.includes('<author><name>Alice&apos;s journal</name></author>'),
147+
'atom feed needs a feed-level <author><name>');
144148

145149
// The three titles, drawn from schema:headline, schema:name, dcterms:title.
146150
for (const t of ['A quiet Tuesday', 'First light', 'Storm over the bay']) {

terminal/plugin.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
// Attribution: adapted from JavaScriptSolidServer/src/terminal/index.js.
2323

2424
import { spawn } from 'node:child_process';
25+
import { createHash, timingSafeEqual } from 'node:crypto';
2526

2627
// Wire protocol (matches core): raw text/binary chunks are shell stdio;
2728
// JSON envelopes carry lifecycle signals.
@@ -42,6 +43,9 @@ export async function activate(api) {
4243
// (browsers cannot set Authorization on a WebSocket handshake).
4344
const allowAgents = Array.isArray(cfg.allowAgents) ? cfg.allowAgents.filter(Boolean) : null;
4445
const token = typeof cfg.token === 'string' && cfg.token ? cfg.token : null;
46+
// Pre-hash the expected token so the compare is constant-time over
47+
// fixed-width digests — no early-exit that would leak the token length.
48+
const tokenDigest = token ? createHash('sha256').update(token).digest() : null;
4549
if ((!allowAgents || allowAgents.length === 0) && !token) {
4650
throw new Error(
4751
'terminal: refusing to boot an open shell — set config.allowAgents ' +
@@ -71,14 +75,14 @@ export async function activate(api) {
7175
// Verify the handshake before spawning anything. Returns an agent label
7276
// string when authorized, or null.
7377
async function authorize(request) {
74-
// Browser fallback: a shared token in the query string.
78+
// Browser fallback: a shared token in the query string. Constant-time
79+
// over sha256 digests so neither the token nor its LENGTH leaks via an
80+
// early-exit length check (the metrics/ pattern).
7581
if (token) {
7682
const qToken = request.query?.token;
77-
if (typeof qToken === 'string' && qToken.length === token.length) {
78-
// constant-time-ish compare
79-
let diff = 0;
80-
for (let i = 0; i < token.length; i++) diff |= qToken.charCodeAt(i) ^ token.charCodeAt(i);
81-
if (diff === 0) return 'token';
83+
if (typeof qToken === 'string') {
84+
const presented = createHash('sha256').update(qToken).digest();
85+
if (timingSafeEqual(presented, tokenDigest)) return 'token';
8286
}
8387
}
8488
// Primary: a verified agent from a Bearer/DPoP/NIP-98/… credential.

terminal/test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ describe('terminal plugin', () => {
6262
const bad = await open(`${jss.wsBase}/terminal?token=nope`);
6363
assert.strictEqual(bad.opened, false, 'wrong token must not open a usable session');
6464

65+
// A wrong token of the SAME length as the real one is also refused — the
66+
// compare is constant-time over sha256 digests, no length early-exit.
67+
const sameLen = await open(`${jss.wsBase}/terminal?token=${'x'.repeat(TOKEN.length)}`);
68+
assert.strictEqual(sameLen.opened, false, 'same-length wrong token must be refused');
69+
6570
const none = await open(`${jss.wsBase}/terminal`);
6671
assert.strictEqual(none.opened, false, 'missing token must be refused');
6772
});

0 commit comments

Comments
 (0)