[grade=A urn:ump:qszwspay7vhx7wcpqekkkidni3ehhgidvze5qk2y22fvg2wqz3ja] fix(DC-170): reliability batch — hangs, atomicity, fail-closed, SW (audit batch 6)
8 reliability fixes across 8 files (AUDIT-2026-10-27.md findings
9/11/12/13/14/16/20 + calendar credential item):
- dns.js check-update/update HANGS: bare returns + empty else/catch
branches left requests hanging until client timeout; every path now
answers (502 with message); upstream JSON.parse wrapped (non-JSON ->
502)
- calendar.js ICS fetch timeout actually applies: fetchT ignores
opts.timeout (undici) — was running at 5s default, not the intended
15s; now timeoutMs third arg
- calendar.js PUT /sources: updating pass no longer nulls the stored
token (and vice versa) — existing credential record loaded, only the
provided field overwritten
- state-manager.js ATOMIC WRITES: _atomicWrite = tmp file + fs.rename
(POSIX-atomic); write()/update() no longer truncate-then-write the
live file (crash mid-write corrupted services.json/config.json with
no recovery path). Test mock extended to the tmp+rename contract
- dashboard-ws.js auth fallback FAIL-CLOSED: missing authVerifier used
to accept any non-empty session cookie (DC-061 footgun on unwired
boot paths); now rejects + loud CRITICAL log
- middleware.js tailscale gate: new tailscale.failClosed config (default
off = unchanged); when set, outages/errors deny (503) instead of
silently disabling the gate; fail-open path now logs the exact key
- sw.js: offline fallback actually works ((await match) || (await
match) — old Promise||Promise was always truthy); only 2xx cached
(5xx pages were stored and served as the app shell); install is
per-URL tolerant (one bad asset kept the stale SW alive forever)
- config/settings.js GET /config (public): corrupt config.json degrades
to {setupComplete:false, corrupt:true} instead of 500 on every load
SCOPE CORRECTION: audit finding #10 (7 reversed-arg errorResponse
sites) was a FALSE POSITIVE — those files alias message-first error();
see lesson #14. Full suite 173/173 suites, 3740/3740 tests.
This commit is contained in:
8 files changed
+115
-31
No files matched your search
@@ -142,8 +142,21 @@ module.exports = function createCalendarRoutes({
|
||||
if (req.body.user !== undefined) updates.user = req.body.user || null;
|
||||
if (updates.name !== undefined) updates.name = String(updates.name).slice(0, 120);
|
||||
if (req.body.pass !== undefined || req.body.token !== undefined) {
|
||||
// DC-170: preserve the credential TYPE not being updated — the old write
|
||||
// stored {pass: body.pass ?? null, token: body.token ?? null}, so
|
||||
// updating pass on a token-authenticated source (or vice versa) nulled
|
||||
// the other field and broke the source's stored credential.
|
||||
let existingCreds = {};
|
||||
try {
|
||||
const raw = await credentialManager.retrieve(`calendar:source:${existing.id}`);
|
||||
if (raw) existingCreds = JSON.parse(raw);
|
||||
} catch (_) { /* start from empty */ }
|
||||
const next = {
|
||||
pass: req.body.pass !== undefined ? req.body.pass : (existingCreds.pass ?? null),
|
||||
token: req.body.token !== undefined ? req.body.token : (existingCreds.token ?? null),
|
||||
};
|
||||
await credentialManager.store(`calendar:source:${existing.id}`,
|
||||
JSON.stringify({ pass: req.body.pass ?? null, token: req.body.token ?? null }),
|
||||
JSON.stringify(next),
|
||||
{ label: `Calendar source ${existing.name}`, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
await calendarStateManager.updateItem(existing.id, updates);
|
||||
@@ -174,7 +187,9 @@ module.exports = function createCalendarRoutes({
|
||||
|
||||
const results = await Promise.allSettled(wanted.map(async (source) => {
|
||||
if (source.type === 'ics') {
|
||||
const resp = await fetchT(source.url, { timeout: 15000 });
|
||||
// DC-170: fetchT ignores opts.timeout (undici) — the 15s intent ran at
|
||||
// the 5s HTTP_DEFAULT. Pass it as the third arg (timeoutMs).
|
||||
const resp = await fetchT(source.url, {}, 15000);
|
||||
if (!resp.ok) throw new Error(`ICS fetch HTTP ${resp.status}`);
|
||||
// Bounded read (judge r3 blocker): same 5 MiB cap as the CalDAV path —
|
||||
// a misbehaving feed must not balloon API memory.
|
||||
|
||||
@@ -28,7 +28,15 @@ module.exports = function({ configStateManager: _configStateManager, asyncHandle
|
||||
return res.json({ setupComplete: false });
|
||||
}
|
||||
const data = await fsp.readFile(ctx.CONFIG_FILE, 'utf8');
|
||||
const config = JSON.parse(data);
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(data);
|
||||
} catch (_) {
|
||||
// DC-170: a corrupt/partial config.json (e.g. non-atomic write crash)
|
||||
// previously 500'd this PUBLIC endpoint on every dashboard load.
|
||||
// Degrade to the setup wizard instead of an error page.
|
||||
return res.json({ setupComplete: false, corrupt: true });
|
||||
}
|
||||
res.json(config);
|
||||
}, 'config-get'));
|
||||
|
||||
|
||||
@@ -718,11 +718,17 @@ module.exports = function({
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// DC-170: every path must answer — bare returns left requests hanging
|
||||
if (!text || text.trim() === '') {
|
||||
return // Error handled by middleware
|
||||
return errorResponse(res, 'Empty response from DNS server update check', 502);
|
||||
}
|
||||
|
||||
const result = JSON.parse(text);
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (_) {
|
||||
return errorResponse(res, 'DNS server returned a non-JSON update response', 502);
|
||||
}
|
||||
|
||||
if (result.status === 'ok') {
|
||||
success(res, {
|
||||
@@ -735,11 +741,11 @@ module.exports = function({
|
||||
instructionsLink: result.response.instructionsLink || null
|
||||
});
|
||||
} else {
|
||||
// Error handled by middleware
|
||||
return errorResponse(res, `DNS server update check failed: ${result.message || result.status}`, 502);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', error, null, { note: 'DNS update check error' });
|
||||
// Error handled by middleware
|
||||
return errorResponse(res, safeErrorMessage(error), error.statusCode || 502);
|
||||
}
|
||||
}, 'dns-check-update'));
|
||||
|
||||
@@ -776,12 +782,17 @@ module.exports = function({
|
||||
|
||||
const checkText = await checkResponse.text();
|
||||
if (!checkText || checkText.trim() === '') {
|
||||
return // Error handled by middleware
|
||||
return errorResponse(res, 'Empty response from DNS server update check', 502);
|
||||
}
|
||||
let checkResult;
|
||||
try {
|
||||
checkResult = JSON.parse(checkText);
|
||||
} catch (_) {
|
||||
return errorResponse(res, 'DNS server returned a non-JSON update response', 502);
|
||||
}
|
||||
const checkResult = JSON.parse(checkText);
|
||||
|
||||
if (checkResult.status !== 'ok') {
|
||||
return // Error handled by middleware
|
||||
return errorResponse(res, `DNS server update check failed: ${checkResult.message || checkResult.status}`, 502);
|
||||
}
|
||||
|
||||
if (!checkResult.response.updateAvailable) {
|
||||
@@ -807,7 +818,7 @@ module.exports = function({
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', error, null, { note: 'DNS update error' });
|
||||
// Error handled by middleware
|
||||
return errorResponse(res, safeErrorMessage(error), error.statusCode || 502);
|
||||
}
|
||||
}, 'dns-update'));
|
||||
|
||||
|
||||
Reference in new issue
Block a user