Skip to content

Commit b138a25

Browse files
forge: WebID/Solid pod-native issue writes — the client-write capstone
DPoP and NIP-98 tokens are request-bound (the proof's htu binds to one URL), so the server cannot forward a browser credential to a loopback pod write — that's why Solid logins 401'd and nostr fell back to forge-hosted. The fix moves the write to where the proof can be signed: the browser. For a WebID/Solid login the client authFetch-PUTs the issue/comment body straight into the author's own pod (fresh per-request DPoP proof), then posts only the pointer; the forge validates the pointer is inside the author's own pod forge area (exact-prefix + .jsonld + no-traversal, cross-user injection 403'd), confirms it reads publicly, and indexes it. Words land in your pod, WAC-governed — the pod-native design DPoP actually demands. Nostr and plain-Bearer paths unchanged; a pointer-based item renders byte-identically to a body-based one. Finding 21 documents the request-bound-proof wall and the client-write answer. 106 tests (8 new: happy path, render-identity, and the pointer-injection defenses).
1 parent 06036e3 commit b138a25

3 files changed

Lines changed: 324 additions & 19 deletions

File tree

forge/README.md

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,13 @@ Every issues page embeds one dependency-free inline
287287

288288
- offers a login box — username/password → `POST /idp/credentials`,
289289
token kept in localStorage, "Signed in as X" + sign-out;
290-
- drives new-issue/comment/close/reopen through `fetch` + Bearer against
291-
the JSON API, then **reloads — the server always renders the truth**;
290+
- drives new-issue/comment/close/reopen through `fetch` + Bearer (or
291+
`xlogin.authFetch` for nostr/Solid) against the JSON API, then
292+
**reloads — the server always renders the truth**;
293+
- for a **Solid (DPoP) login**, first `authFetch`-PUTs the body into the
294+
author's own pod (a fresh per-request proof the server could never
295+
forward) and posts the forge only the `resourceUrl` pointer — Finding
296+
21, the capstone; nostr and plain-Bearer keep posting `{title, body}`;
292297
- touches the DOM only via `textContent`/`createElement`; fetched
293298
strings never meet `innerHTML`.
294299

@@ -992,3 +997,67 @@ importing it:
992997
divergence is written down here and self-described in the served
993998
document (`derivation`, `stateHash` fields) so a future re-deriving
994999
verifier knows exactly what it is looking at.
1000+
1001+
### 21. Request-bound proofs make server-side pod writes impossible — the client writes, the forge validates a pointer (the capstone)
1002+
1003+
Tier 2's storage beat is a server-side loopback PUT into the author's
1004+
pod that FORWARDS the caller's `Authorization` header. That works for a
1005+
plain Bearer (a bearer is replayable to any URL), but it is
1006+
STRUCTURALLY impossible for the two proofs that actually matter for a
1007+
pod-native forge: **Solid-OIDC DPoP and NIP-98 are bound to ONE request
1008+
URI** (DPoP's `htu`, NIP-98's `u`). The browser signs a proof whose URI
1009+
is the forge API URL; forward that same proof to a *different* URL (the
1010+
pod-write) and it has no valid binding → the pod answers 401 → *"your
1011+
pod refused the write"*. The server cannot re-sign, because the signing
1012+
key lives in the browser (WebAuthn/NIP-07), not on the host. This is
1013+
the same request-bound wall Finding 9 hit for git pushes — here it
1014+
blocks the pod WRITE instead of the git wire.
1015+
1016+
The WAC-correct fix moves the write to the only party that can sign for
1017+
it. The **client** (`window.xlogin.authFetch`, which mints a FRESH DPoP
1018+
or NIP-98 proof per request) PUTs the JSON-LD body directly into the
1019+
author's OWN pod — `htu`/`u` now name the pod URL, so WAC governs a real
1020+
write the author owns — and then POSTs the forge only a POINTER
1021+
(`resourceUrl`). The forge stores it in the index identically to a
1022+
server-written pointer (`{author, resourceUrl, at}`), so the display
1023+
path (public loopback GET → `doc.body` → rendered markdown) reads a
1024+
pointer-based issue byte-for-byte the same as a body-based one. Proven
1025+
in the tests: a pointer issue renders the same `<strong>` as a
1026+
body-written one.
1027+
1028+
Two design points that make it safe:
1029+
1030+
- **Pointer-injection defense.** A `resourceUrl` is a client-supplied
1031+
path, so the forge treats it as hostile: the path MUST start with
1032+
exactly `${podPathFromAgent(agent)}public/forge/${owner}--${name}/`,
1033+
end in `.jsonld`, and contain no `..` — else 403. The allowed prefix
1034+
is keyed to the CALLER's pod (derived from the authenticated agent,
1035+
not from the request body), so a caller can register pointers only
1036+
into ITS OWN pod's forge area for THIS repo; eve cannot point at
1037+
casey's pod, and casey cannot point at another repo's dir or escape
1038+
`public/forge/`. Then an UNAUTHENTICATED loopback GET confirms the
1039+
resource is really there and PUBLICLY readable (400 if not) — the
1040+
same public read the display path uses, so registration validates
1041+
exactly what rendering will later fetch. The read is deliberately
1042+
unauthenticated: bodies live under `public/forge/`, the GET is itself
1043+
request-bound and could not carry a forwarded proof anyway, and a
1044+
private body would render as removed for everyone — so requiring
1045+
public-readability at registration is honest about the display
1046+
contract.
1047+
- **The index author is authoritative, not the doc.** The thread entry
1048+
records the AUTHENTICATED agent as `author`; the pod document's own
1049+
`author` field is cosmetic. A doc that lies about its author changes
1050+
nothing the forge trusts.
1051+
1052+
Backward compatibility is total: `resourceUrl` is optional, and only a
1053+
pod agent (`podPathFromAgent` truthy) whose request carries it takes the
1054+
pointer branch. A plain Bearer with no `resourceUrl` still gets the
1055+
server-side write (tier 2 unchanged); a did:nostr agent still gets
1056+
forge-hosted storage (tier 2.5 unchanged, `resourceUrl` ignored — a key
1057+
has no pod to point at, which is Finding 10 restated). The client only
1058+
takes the pod-write path for `xlogin.type === 'solid'`; nostr sessions
1059+
and plain-Bearer logins post `{title, body}` as before. No CSP change:
1060+
the `authFetch` PUT is same-origin (`connect-src 'self'` already admits
1061+
it). The one seam this still wants is the same `api.podOf` ask from
1062+
Finding 10 — a did:nostr key with provisioned storage could take this
1063+
exact client-write path and the hosted asymmetry would finally vanish.

forge/plugin.js

Lines changed: 118 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,6 +1654,55 @@ export async function activate(api) {
16541654
return { url: stored.url, hosted: !!hex };
16551655
}
16561656

1657+
/**
1658+
* The capstone storage beat (WAC-native authored words). A WebID/Solid
1659+
* (DPoP) — or NIP-98 — proof is cryptographically bound to ONE request URI
1660+
* (DPoP `htu`, NIP-98 `u`): the forge CANNOT forward the caller's
1661+
* credential to a SECOND URL (the pod write) because the proof names the
1662+
* API URL, not the pod resource. So the BROWSER writes the body into its
1663+
* OWN pod (a fresh per-request proof, minted by xlogin.authFetch), and the
1664+
* forge is handed only a POINTER. Here the forge VALIDATES that pointer:
1665+
* - it must name a resource inside THIS author's OWN pod forge area for
1666+
* THIS repo — `${podPath}public/forge/${owner}--${name}/…​.jsonld`, no
1667+
* traversal (pointer-injection defense: a caller can register only its
1668+
* own pod's resources for this one repo);
1669+
* - it must really be there and PUBLICLY readable — an UNAUTHENTICATED
1670+
* loopback GET, exactly how resolveEntry re-fetches bodies for display
1671+
* (bodies live under public/forge/, so the read is request-agnostic and
1672+
* needs no forwarded proof).
1673+
* On success the pointer is stored identically to a server-written one.
1674+
* Returns { url } or { status, error }.
1675+
*/
1676+
async function registerPointer(request, agent, owner, name, resourceUrl) {
1677+
const podPath = podPathFromAgent(agent);
1678+
if (!podPath) return { status: 403, error: 'pointer registration requires a pod agent' };
1679+
const p = resourcePathOf(resourceUrl);
1680+
if (!p) return { status: 400, error: 'invalid resourceUrl' };
1681+
const allowed = `${podPath}public/forge/${owner}--${name}/`;
1682+
if (!p.startsWith(allowed) || !p.endsWith('.jsonld') || p.includes('..')) {
1683+
return { status: 403, error: 'resourceUrl must be inside your own pod forge area for this repo' };
1684+
}
1685+
let res;
1686+
try {
1687+
res = await lb(p, {
1688+
headers: { accept: 'application/ld+json' },
1689+
signal: AbortSignal.timeout(THREAD_FETCH_TIMEOUT_MS),
1690+
});
1691+
} catch (err) {
1692+
return { status: 400, error: `pointer not found in your pod: ${err.message}` };
1693+
}
1694+
if (!res.ok) {
1695+
try { await res.body?.cancel(); } catch { /* drained */ }
1696+
return { status: 400, error: 'pointer not found in your pod' };
1697+
}
1698+
let doc;
1699+
try { doc = await res.json(); } catch { doc = null; }
1700+
if (!doc || typeof doc.body !== 'string' || doc.body.length > ISSUE_BODY_CAP) {
1701+
return { status: 400, error: 'pointer is not a readable forge document' };
1702+
}
1703+
return { url: `${publicOrigin()}${p}` };
1704+
}
1705+
16571706
/** Loopback path of a stored resource URL (absolute or path form). */
16581707
function resourcePathOf(resourceUrl) {
16591708
if (typeof resourceUrl !== 'string') return null;
@@ -2336,29 +2385,67 @@ async function call(path,body,method){
23362385
if(!res.ok)throw new Error((j&&j.error)||('HTTP '+res.status));
23372386
return j||{};
23382387
}
2388+
// A Solid/DPoP proof is bound to ONE request URI, so the SERVER cannot
2389+
// forward it to write the pod: the browser signs a FRESH proof per request
2390+
// and writes the body into its own pod, then hands the forge a pointer.
2391+
const Sol=()=>{const x=X();return (x&&x.type==='solid')?x:null};
2392+
async function podWrite(doc,filePrefix){
2393+
const id=new URL(String(window.xlogin.id));
2394+
const segs=id.pathname.split('/').filter(Boolean);
2395+
const podPath=(segs.length>=2&&segs[0]!=='profile')?'/'+segs[0]+'/':'/';
2396+
const resourcePath=podPath+'public/forge/'+CFG.owner+'--'+CFG.name+'/'+filePrefix+'-'+crypto.randomUUID()+'.jsonld';
2397+
const res=await window.xlogin.authFetch(id.origin+resourcePath,
2398+
{method:'PUT',headers:{'content-type':'application/ld+json'},body:JSON.stringify(doc)});
2399+
if(!(res.ok||res.status===201||res.status===204))throw new Error('could not write to your pod: '+res.status);
2400+
return resourcePath;
2401+
}
23392402
const si=document.getElementById('submit-issue');
23402403
if(si)si.onclick=async function(){
23412404
setMsg('');
23422405
try{
2343-
const r=await call('/issues',{title:document.getElementById('f-title').value,
2344-
body:document.getElementById('f-body').value});
2406+
const title=document.getElementById('f-title').value;
2407+
const bodyText=document.getElementById('f-body').value;
2408+
let payload={title:title,body:bodyText};
2409+
if(Sol()){
2410+
const doc={type:'ForgeIssue',repo:CFG.owner+'/'+CFG.name,title:title,body:bodyText,
2411+
published:new Date().toISOString(),author:window.xlogin.id};
2412+
payload={title:title,resourceUrl:await podWrite(doc,'issue')};
2413+
}
2414+
const r=await call('/issues',payload);
23452415
location.href=CFG.base+'/issues/'+r.number;
23462416
}catch(e){setMsg(String(e.message||e))}
23472417
};
23482418
const sp=document.getElementById('submit-pull');
23492419
if(sp)sp.onclick=async function(){
23502420
setMsg('');
23512421
try{
2352-
const r=await call('/pulls',{title:document.getElementById('f-title').value,
2353-
body:document.getElementById('f-body').value,base:CFG.prBase,head:CFG.prHead});
2422+
const title=document.getElementById('f-title').value;
2423+
const bodyText=document.getElementById('f-body').value;
2424+
let payload={title:title,body:bodyText,base:CFG.prBase,head:CFG.prHead};
2425+
if(Sol()){
2426+
const doc={type:'ForgePullRequest',repo:CFG.owner+'/'+CFG.name,title:title,body:bodyText,
2427+
base:CFG.prBase,head:CFG.prHead,published:new Date().toISOString(),author:window.xlogin.id};
2428+
payload={title:title,base:CFG.prBase,head:CFG.prHead,resourceUrl:await podWrite(doc,'pull')};
2429+
}
2430+
const r=await call('/pulls',payload);
23542431
location.href=CFG.base+'/pulls/'+r.number;
23552432
}catch(e){setMsg(String(e.message||e))}
23562433
};
23572434
const sc=document.getElementById('submit-comment');
23582435
if(sc)sc.onclick=async function(){
23592436
setMsg('');
23602437
try{
2361-
await call(CFG.thread+'/comments',{body:document.getElementById('f-body').value});
2438+
const bodyText=document.getElementById('f-body').value;
2439+
let payload={body:bodyText};
2440+
if(Sol()){
2441+
const isPull=String(CFG.thread||'').indexOf('/pulls/')===0;
2442+
const num=parseInt(String(CFG.thread||'').split('/').pop(),10);
2443+
const doc={type:'ForgeComment',repo:CFG.owner+'/'+CFG.name,
2444+
published:new Date().toISOString(),author:window.xlogin.id,body:bodyText};
2445+
if(isPull)doc.pull=num;else doc.issue=num;
2446+
payload={resourceUrl:await podWrite(doc,'comment')};
2447+
}
2448+
await call(CFG.thread+'/comments',payload);
23622449
location.reload();
23632450
}catch(e){setMsg(String(e.message||e))}
23642451
};
@@ -2494,7 +2581,7 @@ ${authBox('Commenting, closing, or reopening')}
24942581
<button id="submit-comment" class="btn btn-primary" type="button" disabled>Comment</button>
24952582
</div></div></div>
24962583
</div></main>
2497-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: `/issues/${issue.number}`, state: issue.state })}`;
2584+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: `/issues/${issue.number}`, state: issue.state })}`;
24982585
return sendHtml(reply, 200, page(`${issue.title} · #${issue.number} · ${owner}/${name}`, body));
24992586
}
25002587

@@ -2513,7 +2600,7 @@ ${authBox('Opening an issue')}
25132600
<button id="submit-issue" class="btn btn-primary" type="button" disabled>Submit new issue</button>
25142601
</div></div></div>
25152602
</div></main>
2516-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null, state: null })}`;
2603+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null })}`;
25172604
return sendHtml(reply, 200, page(`New issue · ${owner}/${name}`, body));
25182605
}
25192606

@@ -2675,7 +2762,7 @@ ${pr.state !== 'merged' ? `<button id="toggle-state" class="btn" type="button" d
26752762
<button id="submit-comment" class="btn btn-primary" type="button" disabled>Comment</button>
26762763
</div></div></div>
26772764
</div></main>
2678-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: `/pulls/${pr.number}`, state: pr.state, expectedBase: info?.baseSha ?? null })}`;
2765+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: `/pulls/${pr.number}`, state: pr.state, expectedBase: info?.baseSha ?? null })}`;
26792766
return sendHtml(reply, 200, page(`${pr.title} · #${pr.number} · ${owner}/${name}`, body));
26802767
}
26812768

@@ -2733,7 +2820,7 @@ ${authBox('Opening a pull request')}
27332820
<button id="submit-pull" class="btn btn-primary" type="button" disabled>Create pull request</button>
27342821
</div></div></div>
27352822
</div></main>
2736-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null, state: null, prBase: baseRef, prHead: headSpec })}`;
2823+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null, prBase: baseRef, prHead: headSpec })}`;
27372824
return sendHtml(reply, 200, page(`New pull request · ${owner}/${name}`, body));
27382825
}
27392826

@@ -2856,7 +2943,7 @@ ${authBox('Enabling anchoring')}
28562943
</div>
28572944
</div>
28582945
</div></main>
2859-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null, state: null })}`;
2946+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null })}`;
28602947
return sendHtml(reply, 200, page(`Anchors · ${owner}/${name}`, body));
28612948
}
28622949

@@ -2924,7 +3011,7 @@ ${rows}
29243011
</table></div>
29253012
${fundBox}
29263013
</div></main>
2927-
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null, state: null, markIndex: firstPending ? firstPending.index : null })}`;
3014+
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null, markIndex: firstPending ? firstPending.index : null })}`;
29283015
return sendHtml(reply, 200, page(`Anchors · ${owner}/${name}`, body));
29293016
}
29303017

@@ -3141,6 +3228,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31413228
if (!p) return apiErr(reply, 400, 'invalid JSON body');
31423229
const title = typeof p.title === 'string' ? p.title.trim() : '';
31433230
const bodyText = typeof p.body === 'string' ? p.body : '';
3231+
// Capstone: a pod agent may hand a POINTER (client already wrote the body
3232+
// into its own pod via authFetch) instead of a server-written body.
3233+
const usePointer = !!podPath && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0;
31443234
if (!title || title.length > ISSUE_TITLE_CAP) return apiErr(reply, 422, `title required (1-${ISSUE_TITLE_CAP} chars)`);
31453235
if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large');
31463236
return withIssueLock(owner, name, async () => {
@@ -3158,7 +3248,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31583248
// did:nostr agents have no pod: the forge hosts their words (2.5).
31593249
const stored = nostrHex
31603250
? storeHosted(nostrHex, { ...doc, hosted: true })
3161-
: await storeAuthored(request, podPath, owner, name, doc, `issue-${crypto.randomUUID()}.jsonld`);
3251+
: usePointer
3252+
? await registerPointer(request, agent, owner, name, p.resourceUrl)
3253+
: await storeAuthored(request, podPath, owner, name, doc, `issue-${crypto.randomUUID()}.jsonld`);
31623254
if (stored.error) return apiErr(reply, stored.status, stored.error);
31633255
const at = Math.floor(Date.now() / 1000);
31643256
idx.next = number + 1;
@@ -3189,7 +3281,8 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31893281
if (!podPath && !nostrHex) return apiErr(reply, 403, 'no pod namespace for this agent');
31903282
if (!p) return apiErr(reply, 400, 'invalid JSON body');
31913283
const bodyText = typeof p.body === 'string' ? p.body : '';
3192-
if (!bodyText.trim()) return apiErr(reply, 422, 'body required');
3284+
const usePointer = !!podPath && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0;
3285+
if (!usePointer && !bodyText.trim()) return apiErr(reply, 422, 'body required');
31933286
if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large');
31943287
return withIssueLock(owner, name, async () => {
31953288
const idx = loadIssueIndex(owner, name);
@@ -3206,7 +3299,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
32063299
};
32073300
const stored = nostrHex
32083301
? storeHosted(nostrHex, { ...doc, hosted: true })
3209-
: await storeAuthored(request, podPath, owner, name, doc, `comment-${crypto.randomUUID()}.jsonld`);
3302+
: usePointer
3303+
? await registerPointer(request, agent, owner, name, p.resourceUrl)
3304+
: await storeAuthored(request, podPath, owner, name, doc, `comment-${crypto.randomUUID()}.jsonld`);
32103305
if (stored.error) return apiErr(reply, stored.status, stored.error);
32113306
issue.thread.push({
32123307
author: agent,
@@ -3624,7 +3719,10 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36243719
published: new Date().toISOString(),
36253720
author: agent,
36263721
};
3627-
const stored = await persistBody(request, agent, owner, name, doc, 'pull');
3722+
const usePointer = !!podPathFromAgent(agent) && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0;
3723+
const stored = usePointer
3724+
? await registerPointer(request, agent, owner, name, p.resourceUrl)
3725+
: await persistBody(request, agent, owner, name, doc, 'pull');
36283726
if (stored.error) return apiErr(reply, stored.status, stored.error);
36293727
const at = Math.floor(Date.now() / 1000);
36303728
idx.next = number + 1;
@@ -3655,7 +3753,8 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36553753
if (!agent) return reply;
36563754
if (!p) return apiErr(reply, 400, 'invalid JSON body');
36573755
const bodyText = typeof p.body === 'string' ? p.body : '';
3658-
if (!bodyText.trim()) return apiErr(reply, 422, 'body required');
3756+
const usePointer = !!podPathFromAgent(agent) && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0;
3757+
if (!usePointer && !bodyText.trim()) return apiErr(reply, 422, 'body required');
36593758
if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large');
36603759
return withPullLock(owner, name, async () => {
36613760
const idx = loadPullIndex(owner, name);
@@ -3670,7 +3769,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36703769
published: new Date().toISOString(),
36713770
author: agent,
36723771
};
3673-
const stored = await persistBody(request, agent, owner, name, doc, 'comment');
3772+
const stored = usePointer
3773+
? await registerPointer(request, agent, owner, name, p.resourceUrl)
3774+
: await persistBody(request, agent, owner, name, doc, 'comment');
36743775
if (stored.error) return apiErr(reply, stored.status, stored.error);
36753776
pr.thread.push({
36763777
author: agent,

0 commit comments

Comments
 (0)