Files
Haven/src/extensionUpdates.js
T
ancsemi 5467e61a72 Extension updates: keep file permissions, log why a blocklist check failed
A replaced plugin or theme kept the private mode of the temporary file;
it now keeps the original file's mode. The blocklist error gave no cause,
so a 404, a DNS failure and bad JSON looked the same in the log. Also a
colon instead of a dash in the module header.
2026-10-03 15:40:52 -04:00

648 lines
26 KiB
JavaScript

'use strict';
/**
* Haven: manual plugin and theme updates (#5578)
*
* Check builds a short-lived offer from an immutable GitHub release. Apply
* revalidates that offer, checks the downloaded bytes, and replaces the existing
* file without changing its filename or the user's extension preferences.
*
* The blocklist is required for both updates and rollback. A failed security
* check leaves installed extensions running but prevents any replacement.
* Registry, backups and the recovery journal live outside the public folders.
*/
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const semver = require('semver');
const https = require('node:https');
const dns = require('node:dns');
const net = require('node:net');
// Published from docs/blocklist.json by the website. The old github.io
// address still redirects here and stays allowed for older servers.
const BLOCKLIST_URL = 'https://haven-app.com/blocklist.json';
const EXTENSION_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const REPOSITORY_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38})\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const extensionSuffix = type => (type === 'plugin' ? '.plugin.js' : '.theme.css');
const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
function assertValid(ok, message) {
if (!ok) throw new Error(message);
}
function isStableVersion(version) {
return typeof version === 'string' && semver.valid(version) === version && !semver.prerelease(version);
}
// ── Extension metadata ────────────────────────────────────────────
// Read declarations without evaluating plugin code. Duplicate identity fields
// are ambiguous, so do not silently choose one as the approved update source.
function parseExtensionHeader(bytes) {
const block = bytes.toString('utf8').match(/\/\*\*[\s\S]*?\*\//)?.[0] || '';
const fields = {};
for (const name of ['id', 'version', 'update-repo']) {
const matches = [...block.matchAll(new RegExp('^\\s*\\*?\\s*@' + name + '\\s+([^\\r\\n]+)', 'gm'))];
assertValid(matches.length <= 1, 'Duplicate extension header field: ' + name);
fields[name] = matches[0]?.[1].trim();
}
return fields;
}
// Validate every entry before choosing a candidate. Asset names are basenames
// within a GitHub release; manifests cannot supply a destination path or URL.
function validateReleaseManifest(value) {
assertValid(
value?.schemaVersion === 1 &&
Array.isArray(value.extensions) &&
value.extensions.length > 0 &&
value.extensions.length <= 100,
'Invalid release manifest.',
);
const ids = new Set();
for (const e of value.extensions) {
assertValid(
e && typeof e.id === 'string' && EXTENSION_ID_PATTERN.test(e.id) && !ids.has(e.id),
'Invalid or duplicate extension ID.',
);
ids.add(e.id);
assertValid(
['plugin', 'theme'].includes(e.type) && isStableVersion(e.version),
'Invalid extension type or version.',
);
assertValid(
typeof e.requires?.haven === 'string' &&
e.requires.haven.length <= 128 &&
semver.validRange(e.requires.haven),
'Invalid Haven compatibility range.',
);
assertValid(
typeof e.asset === 'string' &&
/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/.test(e.asset) &&
e.asset.endsWith(extensionSuffix(e.type)),
'Invalid release asset filename.',
);
assertValid(typeof e.sha256 === 'string' && SHA256_PATTERN.test(e.sha256), 'Invalid SHA-256 checksum.');
}
return value;
}
function validateBlocklist(value) {
assertValid(
value?.schemaVersion === 1 &&
typeof value.updatedAt === 'string' &&
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(value.updatedAt) &&
Number.isFinite(Date.parse(value.updatedAt)) &&
Array.isArray(value.blocked) &&
value.blocked.length <= 10000,
'Invalid security blocklist.',
);
for (const e of value.blocked) {
assertValid(
e &&
typeof e.repo === 'string' &&
REPOSITORY_PATTERN.test(e.repo) &&
typeof e.id === 'string' &&
EXTENSION_ID_PATTERN.test(e.id) &&
Array.isArray(e.versions) &&
e.versions.length > 0 &&
e.versions.every(isStableVersion) &&
typeof e.reason === 'string' &&
e.reason.length > 0 &&
e.reason.length <= 2000,
'Invalid security blocklist entry.',
);
}
return value;
}
// IDs are author-chosen. Include the repository when matching an advisory so
// a collision in another author's repository does not block this extension.
function getBlockedReason(list, item) {
return list.blocked.find(
e =>
e.repo.toLowerCase() === item.repo.toLowerCase() &&
e.id === item.id &&
e.versions.includes(item.version),
)?.reason;
}
// ── Bounded GitHub downloads ──────────────────────────────────────
// Force public IPv4 DNS results and pin the selected address to the connection.
// No proxy environment, caller-supplied host, cookies or authorization headers.
function isPublicAddress(address) {
if (net.isIP(address) !== 4) return false;
const [a, b] = address.split('.').map(Number);
return !(
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && [0, 168].includes(b)) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && [18, 19, 51].includes(b)) ||
(a === 203 && b === 0)
);
}
// Redirects keep the original deadline and re-enter URL/DNS validation. This
// allows GitHub's asset CDN without granting arbitrary outbound access.
function downloadExtensionAsset(url, limit, redirects = 0, deadline = Date.now() + 20000) {
return new Promise((resolve, reject) => {
let u;
try {
u = new URL(url);
assertValid(
u.protocol === 'https:' && !u.username && !u.password && (!u.port || u.port === '443') && !u.hash,
'Unsafe download URL.',
);
assertValid(
['api.github.com', 'github.com', 'release-assets.githubusercontent.com'].includes(u.hostname) ||
(!u.search && (
(u.origin === 'https://ancsemi.github.io' && u.pathname === '/Haven/blocklist.json') ||
// The repository's Pages site redirects to its configured domain.
// Allow only the blocklist file there, not arbitrary site URLs.
(u.origin === 'https://haven-app.com' && u.pathname === '/blocklist.json')
)),
'Unapproved download host.',
);
assertValid(redirects <= 4 && deadline > Date.now(), 'Download timed out or redirected too often.');
} catch (e) {
reject(e);
return;
}
const req = https.get(
u,
{
headers: {
'User-Agent': 'Haven-extension-updater',
Accept: /\/releases\/assets\/\d+$/.test(u.pathname)
? 'application/octet-stream'
: 'application/vnd.github+json',
'Cache-Control': 'no-cache',
},
lookup(hostname, options, cb) {
dns.lookup(hostname, { family: 4 }, (err, address, family) => {
if (err) return cb(err);
if (!isPublicAddress(address)) return cb(new Error('Download resolved to a non-public address.'));
if (options.all) cb(null, [{ address, family }]);
else cb(null, address, family);
});
},
},
res => {
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
res.destroy();
clearTimeout(timer);
try {
resolve(
downloadExtensionAsset(new URL(res.headers.location, u).href, limit, redirects + 1, deadline),
);
} catch (e) {
reject(e);
}
return;
}
if (res.statusCode !== 200) {
res.destroy();
clearTimeout(timer);
reject(new Error('Download failed (HTTP ' + res.statusCode + ').'));
return;
}
if (Number(res.headers['content-length']) > limit) {
res.destroy(new Error('Download exceeds size limit.'));
}
const chunks = [];
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > limit) res.destroy(new Error('Download exceeds size limit.'));
else chunks.push(chunk);
});
// A connection that closes without finishing (a timeout or a reset)
// must reject, or the update lock stays taken until a restart.
let ended = false;
res.on('error', reject);
res.on('end', () => {
ended = true;
resolve(Buffer.concat(chunks));
});
res.on('close', () => {
clearTimeout(timer);
if (!ended) reject(new Error('Download ended before it finished.'));
});
},
);
const timer = setTimeout(
() => req.destroy(new Error('Download timed out.')),
Math.max(1, deadline - Date.now()),
);
req.on('error', e => {
clearTimeout(timer);
reject(e);
});
});
}
// ── Installed state and recovery ──────────────────────────────────
// One updater owns the installation directories for this server process.
// Injected transport/clock keep tests deterministic and off the public network.
function createExtensionUpdater({
dirs,
stateDir,
havenVersion,
fetchBytes = downloadExtensionAsset,
now = Date.now,
}) {
// Offers stay server-side: the browser submits a token, not replacement
// metadata. A restart deliberately requires the admin to check again.
const offers = new Map();
// Manifests from immutable releases never change, so each is fetched
// once per process. GitHub allows 60 unauthenticated requests an hour,
// and re-reading every manifest on each check ran out after a few.
const manifestCache = new Map();
let busy = false;
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
const stateFile = path.join(stateDir, 'state.json');
const journalFile = path.join(stateDir, 'transaction.json');
// Stage beside the destination so rename stays on the same filesystem.
// Readers see a complete old or new file, never a partly written download.
// `mode` is the new file's permissions: private for Haven's own state, the
// original file's for a replaced plugin or theme so it stays readable.
function writeFileAtomic(file, bytes, mode = 0o600) {
const tmp = file + '.' + crypto.randomUUID() + '.tmp';
try {
const fd = fs.openSync(tmp, 'wx', mode);
try {
fs.writeFileSync(fd, bytes);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
}
}
function writeJsonAtomic(file, value) {
writeFileAtomic(file, Buffer.from(JSON.stringify(value)));
}
function readInstalledState() {
return fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {};
}
function installedFilePath(item) {
assertValid(
['plugin', 'theme'].includes(item.type) &&
typeof item.file === 'string' &&
path.basename(item.file) === item.file &&
!item.file.includes('\\') &&
item.file.endsWith(extensionSuffix(item.type)),
'Invalid installed filename.',
);
const file = path.join(dirs[item.type], item.file);
assertValid(fs.lstatSync(file).isFile(), 'Extension must be a regular file, not a symlink.');
return file;
}
// A crash can happen between the file swap and registry write. The journal
// records both states; the actual file hash tells us which one to restore.
// An unexpected hash is a local-edit conflict, not permission to overwrite it.
function recoverInterruptedUpdate() {
if (!fs.existsSync(journalFile)) return;
const tx = JSON.parse(fs.readFileSync(journalFile, 'utf8'));
const current = sha256(fs.readFileSync(installedFilePath(tx.item)));
assertValid(
current === tx.before || current === tx.after,
'An interrupted update conflicts with local edits. Restore the extension file before retrying.',
);
writeJsonAtomic(stateFile, current === tx.after ? tx.newState : tx.oldState);
fs.unlinkSync(journalFile);
}
// Check also mutates the offer map, so serialize it with apply/rollback.
// This is an in-process lock, not coordination for multiple Haven processes.
async function withUpdateLock(fn) {
assertValid(!busy, 'An extension operation is already running. Try again when it finishes.');
busy = true;
try {
recoverInterruptedUpdate();
return await fn();
} finally {
busy = false;
}
}
// Keep the original filenames: existing plugin choices and published theme
// settings use them as keys. Once managed, compare against saved identity and
// bytes rather than accepting a new source from a modified local header.
function readInstalledExtensions() {
const result = [];
const saved = readInstalledState();
for (const type of ['plugin', 'theme']) {
for (const file of fs.readdirSync(dirs[type]).filter(f => f.endsWith(extensionSuffix(type)))) {
const key = type + ':' + file;
const item = { type, file, key };
const previous = saved[key];
// A bad file belongs on its own row. It must not prevent checking or
// updating an unrelated extension, and never receives an offer itself.
try {
const bytes = fs.readFileSync(installedFilePath(item));
const h = parseExtensionHeader(bytes);
if (!h.id && !h['update-repo']) {
// Removing a managed file's headers is still a local edit, not an
// opt-out that lets us forget the saved identity and checksum.
assertValid(!previous, 'Local edits or source changes detected in ' + file +
'. Restore the managed file before updating.');
continue;
}
const repo = h['update-repo'];
assertValid(
typeof h.id === 'string' &&
EXTENSION_ID_PATTERN.test(h.id) &&
typeof repo === 'string' &&
REPOSITORY_PATTERN.test(repo) &&
isStableVersion(h.version),
'Invalid update metadata in ' + file + '.',
);
Object.assign(item, { id: h.id, repo, version: h.version, sha256: sha256(bytes) });
if (previous)
assertValid(
previous.id === item.id &&
previous.repo.toLowerCase() === repo.toLowerCase() &&
previous.sha256 === item.sha256 &&
previous.version === item.version,
'Local edits or source changes detected in ' + file +
'. Restore the managed file before updating.',
);
} catch (e) {
item.error = e.message;
}
result.push(item);
}
}
// Every duplicate is ambiguous, regardless of directory enumeration order.
// Invalidate all matching rows rather than offering whichever was read first.
const identities = new Map();
for (const item of result) {
if (!item.repo || !item.id) continue;
const identity = item.repo.toLowerCase() + ':' + item.id;
const duplicate = identities.get(identity);
if (duplicate) {
const error = 'Duplicate installed extension ID: ' + item.id;
duplicate.error = duplicate.error || error;
item.error = item.error || error;
} else {
identities.set(identity, item);
}
}
return result;
}
// Never interpret an unavailable or malformed list as "nothing blocked".
// No cached fallback: the maintainer requires a fresh successful security
// check before either installation or rollback.
async function fetchBlocklist() {
try {
return validateBlocklist(JSON.parse((await fetchBytes(BLOCKLIST_URL, 1024 * 1024)).toString('utf8')));
} catch (err) {
console.warn('[ExtensionUpdates] blocklist check failed:', err.message);
throw new Error(
'The security blocklist could not be fetched or validated. Existing extensions keep running; installation and rollback are unavailable until a fresh check succeeds.',
);
}
}
async function fetchGitHubJson(url) {
return JSON.parse((await fetchBytes(url, 2 * 1024 * 1024)).toString('utf8'));
}
function findReleaseAsset(release, name) {
const found = release.assets?.filter(a => a.name === name);
assertValid(
found?.length === 1 &&
Number.isSafeInteger(found[0].id) &&
found[0].id > 0 &&
found[0].state === 'uploaded',
'Missing or ambiguous release asset: ' + name,
);
return found[0];
}
// ── Manual discovery and confirmation offers ────────────────────
async function check(userId) {
return withUpdateLock(async () => {
offers.clear();
const list = await fetchBlocklist();
const installed = readInstalledExtensions();
const rows = [];
const repos = new Map();
for (const item of installed) {
const row = { ...item, warning: item.error ? null : getBlockedReason(list, item) || null, offers: [] };
rows.push(row);
if (item.error) continue;
let updateOffer;
try {
if (!repos.has(item.repo.toLowerCase())) {
const releases = [];
let complete = false;
for (let page = 1; page <= 5; page++) {
const batch = await fetchGitHubJson(
`https://api.github.com/repos/${item.repo}/releases?per_page=100&page=${page}`,
);
assertValid(Array.isArray(batch), 'Invalid GitHub release list.');
releases.push(...batch);
if (batch.length < 100) {
complete = true;
break;
}
}
assertValid(complete, 'Too many releases to complete this check.');
const entries = [];
for (const r of releases) {
if (r.draft || r.prerelease || r.immutable !== true) continue;
if (!r.assets?.some(a => a.name === 'haven-release.json')) continue;
// GitHub lists the newest releases first; the newest 30 are
// plenty to find an update, and older ones are not read.
if (entries.length >= 30) break;
const a = findReleaseAsset(r, 'haven-release.json');
const cacheKey = item.repo.toLowerCase() + ':' + a.id;
let m = manifestCache.get(cacheKey);
if (!m) {
m = validateReleaseManifest(
await fetchGitHubJson(`https://api.github.com/repos/${item.repo}/releases/assets/${a.id}`),
);
manifestCache.set(cacheKey, m);
}
entries.push({ release: r, manifest: m });
}
repos.set(item.repo.toLowerCase(), entries);
}
const candidates = [];
for (const { release, manifest: m } of repos.get(item.repo.toLowerCase())) {
for (const e of m.extensions) {
if (
e.id !== item.id ||
e.type !== item.type ||
!semver.gt(e.version, item.version) ||
!semver.satisfies(havenVersion, e.requires.haven)
)
continue;
const reason = getBlockedReason(list, { ...item, version: e.version });
if (reason) {
row.blockedUpdate = e.version + ': ' + reason;
continue;
}
const a = findReleaseAsset(release, e.asset);
candidates.push({
item,
candidate: e,
assetId: a.id,
releaseId: release.id,
notes: String(release.body || '').slice(0, 20000),
releaseUrl: `https://github.com/${item.repo}/releases/tag/${encodeURIComponent(release.tag_name)}`,
action: 'install',
});
}
}
// GitHub's latest tag need not be the newest compatible extension.
// Compare extension versions and reject duplicate-version ambiguity.
candidates.sort((a, b) => semver.rcompare(a.candidate.version, b.candidate.version));
for (let i = 1; i < candidates.length; i++)
assertValid(
candidates[i].candidate.version !== candidates[i - 1].candidate.version,
'Conflicting releases for the same version.',
);
updateOffer = candidates[0];
} catch (e) {
row.error = e.message;
}
// Rollback uses local bytes and a fresh blocklist; a GitHub outage must
// not prevent recovery when the security check itself succeeded.
const backup = readInstalledState()[item.key]?.backup;
const rollback =
backup &&
backup.havenVersion === havenVersion &&
!getBlockedReason(list, { ...item, version: backup.version })
? {
item,
candidate: backup,
action: 'rollback',
notes: 'Restore the previous locally saved version.',
releaseUrl: null,
}
: null;
for (const offer of [updateOffer, rollback].filter(Boolean)) {
const token = crypto.randomUUID();
offers.set(token, { ...offer, userId, expires: now() + 10 * 60 * 1000 });
row.offers.push({
token,
action: offer.action,
version: offer.candidate.version,
notes: offer.notes,
releaseUrl: offer.releaseUrl,
});
}
}
return { extensions: rows };
});
}
// ── Verified replacement / rollback ─────────────────────────────
async function apply(token, userId, authorized = () => true) {
return withUpdateLock(async () => {
const offer = offers.get(token);
assertValid(
offer && offer.userId === userId && offer.expires > now(),
'This update offer expired. Check for updates again.',
);
const list = await fetchBlocklist();
const { item, candidate } = offer;
assertValid(
!getBlockedReason(list, { ...item, version: candidate.version }),
'This version is blocked for security reasons.',
);
const current = readInstalledExtensions().find(e => e.key === item.key);
assertValid(!current?.error, current?.error);
assertValid(
current && current.sha256 === item.sha256,
'The installed file changed. Check for updates again.',
);
let bytes;
if (offer.action === 'install') {
const r = await fetchGitHubJson(`https://api.github.com/repos/${item.repo}/releases/${offer.releaseId}`);
assertValid(
r.immutable === true && !r.draft && !r.prerelease && findReleaseAsset(r, candidate.asset).id === offer.assetId,
'The release changed or is not immutable. Check for updates again.',
);
bytes = await fetchBytes(
`https://api.github.com/repos/${item.repo}/releases/assets/${offer.assetId}`,
5 * 1024 * 1024,
);
} else {
assertValid(SHA256_PATTERN.test(candidate.sha256), 'Invalid rollback checksum.');
bytes = fs.readFileSync(path.join(stateDir, candidate.sha256 + '.backup'));
}
assertValid(
sha256(bytes) === candidate.sha256,
'Checksum mismatch. The installed file was not changed.',
);
const h = parseExtensionHeader(bytes);
assertValid(
h.id === item.id &&
h.version === candidate.version &&
h['update-repo']?.toLowerCase() === item.repo.toLowerCase(),
'Downloaded extension metadata does not match the approved offer.',
);
// Network requests may have taken time: the admin could have been
// demoted or the installed file edited since confirmation. Check both
// again before entering the synchronous file-replacement sequence.
assertValid(authorized(), 'Administrator permission is required.');
const file = installedFilePath(item);
const oldBytes = fs.readFileSync(file);
assertValid(sha256(oldBytes) === item.sha256, 'The installed file changed during download.');
if (item.type === 'theme')
assertValid(
require('./themeMetadata').parseThemeMetadata(bytes.toString('utf8')).compatible,
'This theme requires an unsupported Theme API.',
);
const oldState = readInstalledState();
// A rollback consumes the saved previous version. Do not turn the newer
// file into another backup and offer an endless back-and-forth toggle.
const newState = {
...oldState,
[item.key]: {
...item,
version: candidate.version,
sha256: candidate.sha256,
...(offer.action === 'install'
? { backup: { version: item.version, sha256: item.sha256, havenVersion } }
: {}),
},
};
// Ordering matters: preserve the old bytes and journal before swapping
// the live file. Clear the journal only after the new registry is saved.
if (offer.action === 'install')
writeFileAtomic(path.join(stateDir, item.sha256 + '.backup'), oldBytes);
writeJsonAtomic(journalFile, { item, before: item.sha256, after: candidate.sha256, oldState, newState });
writeFileAtomic(file, bytes, fs.statSync(file).mode & 0o777);
writeJsonAtomic(stateFile, newState);
fs.unlinkSync(journalFile);
offers.clear();
return { reloadRequired: true, version: candidate.version };
});
}
// A leftover journal that no longer matches the files must not stop Haven
// from starting. The next check or update runs recovery again and shows
// the admin what is wrong.
try {
recoverInterruptedUpdate();
} catch (err) {
console.warn('[ExtensionUpdates] could not finish an interrupted update at startup:', err.message);
}
return { check, apply };
}
module.exports = {
createExtensionUpdater,
validateReleaseManifest,
validateBlocklist,
parseExtensionHeader,
sha256,
isPublicAddress,
downloadExtensionAsset,
};