FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

markets/: refuse the leaked key, and base CSRF on the resolving crede… · JavaScriptSolidServer/plugins@5029348 · GitHub

Commit 5029348

Browse files
markets/: refuse the leaked key, and base CSRF on the resolving credential
Deleting markets/session.secret from the working tree was never remediation -- the blob is still git-show-able at 3e07250, so anyone with a copy of this repository can forge a session token for any agent on any deployment whose pluginDir was seeded from those commits. The plugin now refuses to boot on that key by digest and tells the operator to delete it, which invalidates every session signed with it. That is the only mitigation a plugin can enforce on its own; the history still wants rewriting. The CSRF rule decided "is this credential ambient?" from whether an Authorization header was present, not from which credential actually authenticated the request. getAgent can authenticate from a WebID-TLS client certificate, which is ambient, so bolting on any junk header made an ambient request look explicitly credentialed. resolveAgentFull now reports how the agent was resolved and the check keys off that. Also: a journal descriptor lost during rotation is recoverable rather than wedging the ledger forever -- if the reopen in the finally block itself throws, commit() reopens lazily instead of failing every trade from then on. And the new `voiding` state is surfaced in the UI: it has its own status colour, the detail page explains that a proposed void means nobody wins and you get back at most what you paid, and holders can dispute it -- previously a state a holder can only lose on was invisible to them.
1 parent 6659441 commit 5029348

4 files changed

Lines changed: 58 additions & 10 deletions

File tree

‎markets/guard.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ const b64url = (buf) => Buffer.from(buf).toString('base64url');
4040
* only this plugin verifies them, so a stolen session token buys markets
4141
* access and nothing else on the pod.
4242
*/
43+
// A session-signing key that was committed to this repo's history by a
44+
// test writing into the source tree. Removing the file from the working
45+
// tree is NOT remediation: the blob is still `git show`-able, so anyone
46+
// with the repo could forge a session token for any agent on any
47+
// deployment whose pluginDir was seeded from those commits. Refusing to
48+
// boot on it is the only mitigation a plugin can enforce by itself.
49+
const BURNED_SECRETS = new Set([
50+
'c59b6df7144548b73d07746635c6774828bf8cc74af8febb8d2ce5db2699d277',
51+
]);
52+
4353
export function createSessions({ dir, ttlMs }) {
4454
const secretFile = path.join(dir, 'session.secret');
4555
let secret;
@@ -49,6 +59,13 @@ export function createSessions({ dir, ttlMs }) {
4959
secret = crypto.randomBytes(32);
5060
fs.writeFileSync(secretFile, secret, { mode: 0o600 });
5161
}
62+
if (BURNED_SECRETS.has(crypto.createHash('sha256').update(secret).digest('hex'))) {
63+
throw new Error(
64+
`markets: ${secretFile} is a key that leaked into git history — anyone with the repository `
65+
+ 'can forge session tokens for any agent. Delete the file (a fresh key is generated on the '
66+
+ 'next boot; every existing session is invalidated, which is the point).',
67+
);
68+
}
5269

5370
const sign = (payload) => crypto.createHmac('sha256', secret).update(payload).digest();
5471

‎markets/plugin.js‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,18 +261,32 @@ export async function activate(api) {
261261
return claims.agent;
262262
}
263263

264-
async function resolveAgent(request) {
264+
/**
265+
* @returns {Promise<{agent:string|null, ambient:boolean}>} `ambient` is
266+
* true when the credential is one a browser attaches by itself — a
267+
* cookie, or a WebID-TLS client certificate — i.e. one a cross-origin
268+
* page could borrow without knowing it.
269+
*/
270+
async function resolveAgentFull(request) {
265271
const cookie = cookieToken(request);
266272
if (cookie) {
267273
const agent = liveSession(cookie);
268-
if (agent) return agent;
274+
if (agent) return { agent, ambient: true };
269275
}
270276
const auth = request.headers.authorization;
271277
if (auth && auth.startsWith('Bearer v1.')) {
272278
const agent = liveSession(auth.slice(7));
273-
if (agent) return agent;
279+
if (agent) return { agent, ambient: false };
274280
}
275-
return api.auth.getAgent(request);
281+
const agent = await api.auth.getAgent(request);
282+
// getAgent may have authenticated from an ambient TLS client
283+
// certificate; only an explicit Authorization header proves the
284+
// caller actually held a secret.
285+
return { agent, ambient: agent ? !auth : false };
286+
}
287+
288+
async function resolveAgent(request) {
289+
return (await resolveAgentFull(request)).agent;
276290
}
277291

278292
// ------------------------------------------------------------ replies
@@ -309,9 +323,9 @@ export async function activate(api) {
309323
async function authed(request, reply, { mutating = true } = {}) {
310324
// Resolve first so an anonymous caller gets a 401 rather than a
311325
// confusing 403; nothing is acted on before the CSRF check below.
312-
const agent = await resolveAgent(request);
326+
const { agent, ambient } = await resolveAgentFull(request);
313327
if (!agent) { err(reply, 401, 'authentication required'); return null; }
314-
if (mutating && !csrfOk(request)) {
328+
if (mutating && ambient && !isSameOrigin(request, originFor(request))) {
315329
err(reply, 403, 'cross-origin request refused — this endpoint is same-origin only');
316330
return null;
317331
}

‎markets/store.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,17 @@ export function createStore({ dir, log, prices }) {
407407
* ledger ahead of the durable one (the old design mutated first and
408408
* 500'd after, leaving memory and disk permanently divergent).
409409
*/
410+
function reopenJournal() {
411+
jfd = fs.openSync(journalFile, 'a');
412+
}
413+
410414
function commit(ev) {
411415
ev.seq = state.seq + 1;
412416
ev.t = ev.t || Date.now();
417+
// A descriptor lost during rotation (or by anything else) must not
418+
// wedge the ledger forever: recover it here rather than failing
419+
// every trade and settlement from now on.
420+
if (jfd === null) reopenJournal();
413421
const line = Buffer.from(`${JSON.stringify(ev)}\n`, 'utf8');
414422
let off = 0;
415423
while (off < line.length) off += fs.writeSync(jfd, line, off, line.length - off);
@@ -443,7 +451,11 @@ export function createStore({ dir, log, prices }) {
443451
// forever. Always get a working descriptor back.
444452
log.error(`markets: journal rotation failed: ${err.message}`);
445453
} finally {
446-
jfd = fs.openSync(journalFile, 'a');
454+
// If THIS throws, jfd stays null and commit() reopens lazily.
455+
jfd = null;
456+
try { reopenJournal(); } catch (err) {
457+
log.error(`markets: could not reopen the journal after rotation: ${err.message}`);
458+
}
447459
}
448460
log.info(`markets: rotated journal at seq ${state.seq} (prior segment retained for audit)`);
449461
}

‎markets/ui.js‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function renderUi(prefix) {
5959
.status{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
6060
.status.open{color:var(--up)} .status.closed{color:var(--ink3)}
6161
.status.resolving{color:var(--down)} .status.disputed{color:var(--bad)}
62+
.status.voiding{color:var(--down)}
6263
.status.resolved{color:var(--accent)} .status.void{color:var(--bad)}
6364
table{width:100%;border-collapse:collapse;font-size:.85rem}
6465
td,th{padding:.45rem .4rem;border-bottom:1px solid var(--line);text-align:left}
@@ -530,6 +531,8 @@ export function renderUi(prefix) {
530531
$('d-meta').innerHTML = '<span class="status ' + esc(m.status) + '">' + esc(m.status) + '</span>'
531532
+ (m.status === 'resolved' && m.resolvedOutcome != null ? ' → <b>' + esc(m.outcomes[m.resolvedOutcome]) + '</b>' : '')
532533
+ (m.status === 'resolving' ? ' → <b>' + esc(m.outcomes[m.resolvedOutcome]) + '</b> · settles ' + new Date(m.settleAt).toLocaleTimeString() + ' (disputable)' : '')
534+
+ (m.status === 'voiding' ? ' → <b>void proposed</b> · settles ' + new Date(m.settleAt).toLocaleTimeString()
535+
+ ' — everyone is refunded at most what they paid, nobody wins (disputable)' : '')
533536
+ ' · <span id="d-countdown">' + (m.tradable ? countdown(m.closesAt)
534537
: 'closed ' + new Date(m.closesAt).toLocaleString()) + '</span>'
535538
+ ' · pool ' + cr(m.liquidity) + ' · ' + m.trades + ' trades'
@@ -590,13 +593,15 @@ export function renderUi(prefix) {
590593
const isOracle = me && (me.agent === m.oracle);
591594
$('oracle-row').classList.toggle('hidden', !(isOracle && m.canResolve));
592595
$('o-outcome').innerHTML = m.outcomes.map((o, i) => '<option value="' + i + '">' + esc(o) + '</option>').join('');
593-
const canDispute = m.status === 'resolving' && pos;
596+
const canDispute = (m.status === 'resolving' || m.status === 'voiding') && pos;
594597
$('dispute-row').classList.toggle('hidden', !canDispute);
595598
if (canDispute) {
596599
// State the bond, the stakes and the deadline BEFORE taking money.
597600
const bond = Math.max(25, pos.totalCost * 0.2);
598-
$('dispute-copy').innerHTML = 'This market resolved as <b>' + esc(m.outcomes[m.resolvedOutcome])
599-
+ '</b>. Disputing stakes a bond of about <b>' + cr(bond) + ' credits</b>, which you '
601+
$('dispute-copy').innerHTML = (m.status === 'voiding'
602+
? 'The oracle has proposed to <b>void</b> this market — nobody wins and you get back at most what you paid. '
603+
: 'This market resolved as <b>' + esc(m.outcomes[m.resolvedOutcome]) + '</b>. ')
604+
+ 'Disputing stakes a bond of about <b>' + cr(bond) + ' credits</b>, which you '
600605
+ '<b>lose</b> unless an operator agrees with you. '
601606
+ (m.disputes ? m.disputes + ' dispute(s) already filed. ' : '')
602607
+ 'If nobody adjudicates in time, the resolution stands.';

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL