Skip to content

Commit ca52373

Browse files
otp/capability/shortlink: atomic JSON persistence (temp + rename)
Audit finding: these tables were written with a direct fs.writeFileSync, so a crash mid-write truncates the whole file and the loader's loadJson swallows the parse error into {} — total loss, and for capability a truncated revoked.json silently revives a revoked token. Each plugin now writes to a unique temp file in the same dir and renameSync's over the target (atomic on one fs); temp cleaned up on failure, no stale .tmp left. Does NOT address the read-modify-write TOCTOU (out of scope, noted). Regression tests assert valid on-disk JSON and no leftover .tmp after a persisting op.
1 parent a8847a3 commit ca52373

6 files changed

Lines changed: 143 additions & 5 deletions

File tree

capability/plugin.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ const MIME = {
5151
};
5252
const mimeFor = (name) => MIME[path.extname(name).toLowerCase()] ?? 'application/octet-stream';
5353

54+
// ------------------------------------------------------- durable write
55+
// Atomic persist: write to a uniquely-named temp file in the SAME directory
56+
// (rename is only atomic within one filesystem), then rename it over the
57+
// target. A crash mid-write truncates the throwaway temp, never the live
58+
// file — so a truncated REVOCATION LEDGER can no longer silently reset to {}
59+
// at boot and let a revoked capability come back to life. The random suffix
60+
// avoids concurrent-writer temp collisions; a failed rename unlinks the temp
61+
// so no partial file and no stale `.tmp` are left behind. (This does NOT
62+
// address the read-modify-write TOCTOU between concurrent persisters — a
63+
// separate, larger concern.)
64+
function atomicWrite(file, data) {
65+
const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
66+
try {
67+
fs.writeFileSync(tmp, data);
68+
fs.renameSync(tmp, file);
69+
} catch (err) {
70+
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
71+
throw err;
72+
}
73+
}
74+
5475
// ---------------------------------------------------------------- token
5576
// Exported as pure functions so tests (and operators) can craft and check
5677
// tokens against a known secret without booting a server.
@@ -117,8 +138,8 @@ export async function activate(api) {
117138
const revoked = new Map(Object.entries(loadJson(revokedFile)).filter(([, exp]) => exp == null || exp > now));
118139
const issued = new Map(Object.entries(loadJson(issuedFile)).filter(([, v]) => v?.exp > now));
119140
const persist = () => {
120-
fs.writeFileSync(revokedFile, JSON.stringify(Object.fromEntries(revoked)));
121-
fs.writeFileSync(issuedFile, JSON.stringify(Object.fromEntries(issued)));
141+
atomicWrite(revokedFile, JSON.stringify(Object.fromEntries(revoked)));
142+
atomicWrite(issuedFile, JSON.stringify(Object.fromEntries(issued)));
122143
};
123144

124145
// -------------------------------------------------------- resources

capability/test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,26 @@ describe('capability plugin', () => {
193193
assert.strictEqual(res.status, 200);
194194
assert.strictEqual((await fetch(`${jss.base}${cap.url}`)).status, 401);
195195
});
196+
197+
it('the revocation ledger persists atomically: valid JSON with the jti, no leftover .tmp', async () => {
198+
const cap = await (await issue({ resource: 'report.pdf', ttl: 3600 }, alice.access_token)).json();
199+
const revoke = await fetch(`${jss.base}/cap/revoke`, {
200+
method: 'POST',
201+
headers: { 'content-type': 'application/json', authorization: `Bearer ${alice.access_token}` },
202+
body: JSON.stringify({ token: cap.token }),
203+
});
204+
assert.strictEqual(revoke.status, 200);
205+
206+
const pluginDir = path.join(jss.root, '.plugins', 'capability');
207+
208+
// The revocation ledger survived the write and parses. A truncated write
209+
// would leave invalid JSON the boot loader swallows into {} — and a
210+
// revoked capability would silently come back to life. That's the bug.
211+
const revoked = JSON.parse(fs.readFileSync(path.join(pluginDir, 'revoked.json'), 'utf8'));
212+
assert.ok(cap.jti in revoked, 'the revoked jti is on disk in the ledger');
213+
214+
// Neither ledger write may leave a temp file behind in the plugin dir.
215+
const leftovers = fs.readdirSync(pluginDir).filter((f) => f.endsWith('.tmp'));
216+
assert.deepStrictEqual(leftovers, [], `no leftover temp files, saw: ${leftovers}`);
217+
});
196218
});

otp/plugin.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,26 @@ function generateCode(len) {
122122
return s;
123123
}
124124

125+
// ------------------------------------------------------- durable write
126+
// Atomic persist: write to a uniquely-named temp file in the SAME directory
127+
// (rename is only atomic within one filesystem), then rename it over the
128+
// target. A crash mid-write truncates the throwaway temp, never the live
129+
// table — so a truncated table can no longer silently reset to {} at boot
130+
// and re-open a consumed OTP row. The random suffix avoids concurrent-writer
131+
// temp collisions; a failed rename unlinks the temp so no partial file and
132+
// no stale `.tmp` are left behind. (This does NOT address the read-modify-
133+
// write TOCTOU between concurrent persisters — a separate, larger concern.)
134+
function atomicWrite(file, data) {
135+
const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
136+
try {
137+
fs.writeFileSync(tmp, data);
138+
fs.renameSync(tmp, file);
139+
} catch (err) {
140+
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
141+
throw err;
142+
}
143+
}
144+
125145
export async function activate(api) {
126146
const prefix = api.prefix || '/otp';
127147
const cfg = api.config || {};
@@ -154,7 +174,7 @@ export async function activate(api) {
154174
// pruned at boot.
155175
const now0 = Math.floor(Date.now() / 1000);
156176
const table = new Map(Object.entries(loadJson(tableFile)).filter(([, e]) => e && e.exp > now0));
157-
const persist = () => fs.writeFileSync(tableFile, JSON.stringify(Object.fromEntries(table)));
177+
const persist = () => atomicWrite(tableFile, JSON.stringify(Object.fromEntries(table)));
158178
const keyFor = (identifier, purpose) => `${identifier}${NUL}${purpose}`;
159179

160180
// ---------------------------------------------------- channel adapter

otp/test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { describe, it, before, after } from 'node:test';
88
import assert from 'node:assert';
9+
import fs from 'node:fs';
910
import path from 'node:path';
1011
import { fileURLToPath } from 'node:url';
1112
import { startJss } from '../helpers.js';
@@ -152,6 +153,28 @@ describe('otp plugin', () => {
152153
assert.strictEqual(statuses[3], 429, 'fourth request throttled');
153154
});
154155

156+
it('persistence is atomic: otp.json stays valid JSON with the row, no leftover .tmp', async () => {
157+
const id = 'durable@example.com';
158+
assert.strictEqual((await requestCode(id, 'session')).status, 201);
159+
160+
// pluginDir lives at <root>/.plugins/<id> (same as capability/'s secret).
161+
const pluginDir = path.join(jss.root, '.plugins', 'otp');
162+
const tableFile = path.join(pluginDir, 'otp.json');
163+
164+
// The whole table survived the write and parses — a truncated write would
165+
// have left invalid JSON that the boot loader swallows into {} (the bug).
166+
const table = JSON.parse(fs.readFileSync(tableFile, 'utf8'));
167+
const rows = Object.values(table);
168+
assert.ok(
169+
rows.some((e) => e && e.identifier === id && e.purpose === 'session'),
170+
'the just-minted OTP row is present on disk',
171+
);
172+
173+
// The atomic write must not leave any temp file behind in the plugin dir.
174+
const leftovers = fs.readdirSync(pluginDir).filter((f) => f.endsWith('.tmp'));
175+
assert.deepStrictEqual(leftovers, [], `no leftover temp files, saw: ${leftovers}`);
176+
});
177+
155178
it('a tampered or missing session token is refused at whoami', async () => {
156179
const id = 'tamper@example.com';
157180
await requestCode(id, 'session');

shortlink/plugin.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,39 @@ export function randomSlug(len = 7) {
6363
return s;
6464
}
6565

66+
// ------------------------------------------------------- durable write
67+
// Atomic persist: write to a uniquely-named temp file in the SAME directory
68+
// (rename is only atomic within one filesystem), then rename it over the
69+
// target. A crash mid-write truncates the throwaway temp, never the live
70+
// links table — so a truncated table can no longer silently reset to {} at
71+
// boot and lose every link. The random suffix avoids concurrent-writer temp
72+
// collisions; a failed rename unlinks the temp so no partial file and no
73+
// stale `.tmp` are left behind. Both the sync (create/delete) and async
74+
// (hit-count flush) writers go through this — a crashed hit-count flush must
75+
// not truncate the table either. (This does NOT address the read-modify-
76+
// write TOCTOU between concurrent persisters — a separate, larger concern.)
77+
function atomicWriteSync(file, data) {
78+
const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
79+
try {
80+
fs.writeFileSync(tmp, data);
81+
fs.renameSync(tmp, file);
82+
} catch (err) {
83+
try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ }
84+
throw err;
85+
}
86+
}
87+
88+
async function atomicWriteAsync(file, data) {
89+
const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
90+
try {
91+
await fsp.writeFile(tmp, data);
92+
await fsp.rename(tmp, file);
93+
} catch (err) {
94+
try { await fsp.unlink(tmp); } catch { /* best-effort cleanup */ }
95+
throw err;
96+
}
97+
}
98+
6699
export async function activate(api) {
67100
const prefix = api.prefix || '/shortlink';
68101
const cfg = api.config || {};
@@ -102,7 +135,7 @@ export async function activate(api) {
102135
};
103136
const links = new Map(Object.entries(loadJson(tableFile)));
104137

105-
const persist = () => fs.writeFileSync(tableFile, JSON.stringify(Object.fromEntries(links)));
138+
const persist = () => atomicWriteSync(tableFile, JSON.stringify(Object.fromEntries(links)));
106139

107140
// Fire-and-forget flush for hit counts: serialized on a promise chain so
108141
// concurrent redirects never interleave writes to the same file. A crash
@@ -111,7 +144,7 @@ export async function activate(api) {
111144
let pending = Promise.resolve();
112145
const persistLazy = () => {
113146
pending = pending
114-
.then(() => fsp.writeFile(tableFile, JSON.stringify(Object.fromEntries(links))))
147+
.then(() => atomicWriteAsync(tableFile, JSON.stringify(Object.fromEntries(links))))
115148
.catch((err) => api.log.warn(`shortlink: hit-count flush failed: ${err.message}`));
116149
};
117150

shortlink/test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { describe, it, after } from 'node:test';
1010
import assert from 'node:assert';
11+
import fs from 'node:fs';
1112
import path from 'node:path';
1213
import { fileURLToPath } from 'node:url';
1314
import { probePort, startJss } from '../helpers.js';
@@ -192,6 +193,24 @@ describe('shortlink plugin', () => {
192193
assert.strictEqual(mine.find((l) => l.slug === slug).hits, 2);
193194
});
194195

196+
it('persistence is atomic: links.json stays valid JSON with the record, no leftover .tmp', async () => {
197+
const res = await post(alice, { url: `${base}/alice/durable/thing.ttl`, slug: 'durable-link' });
198+
assert.strictEqual(res.status, 201);
199+
200+
const pluginDir = path.join(jss.root, '.plugins', 'shortlink');
201+
const tableFile = path.join(pluginDir, 'links.json');
202+
203+
// The table survived the write and parses. A truncated write would leave
204+
// invalid JSON the boot loader swallows into {} — losing every link.
205+
const table = JSON.parse(fs.readFileSync(tableFile, 'utf8'));
206+
assert.ok(table['durable-link'], 'the just-created link is present on disk');
207+
assert.strictEqual(table['durable-link'].url, `${base}/alice/durable/thing.ttl`);
208+
209+
// The atomic write must not leave any temp file behind in the plugin dir.
210+
const leftovers = fs.readdirSync(pluginDir).filter((f) => f.endsWith('.tmp'));
211+
assert.deepStrictEqual(leftovers, [], `no leftover temp files, saw: ${leftovers}`);
212+
});
213+
195214
it('unknown slug → 404, even one far past maxParamLength (wildcard route)', async () => {
196215
assert.strictEqual((await follow('n0such1')).status, 404);
197216
const long = 'x'.repeat(150); // a named :slug param would 404 at the router with Fastify's default body

0 commit comments

Comments
 (0)