Drops share-store's private _atomicWriteJSON copy (pid+Date.now() tmp names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js: fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync, cleanup-on-failure. Also fixes a latent bug in the same file: .share-secret signing-key persistence used plain fs.writeFileSync — a torn write would silently rotate the HMAC key on next boot, invalidating every outstanding share signature (links 404, no error logged). Now atomicWriteFile. Sole consumers parse JSON / trim(), so the dropped trailing newline on shares.json is unobservable. +2 regression tests pin 0600 on both files, complete content, zero tmp leftovers, and HMAC-still-verifies via getRaw. Judge: GLM-5.3 cold read, round-1 A, deleg_50d5c239 (41s, 4 calls). Verdict: urn:ump:ehblegyr5eko4hgyfylnrqk72hh3crmc43o2yt5ragfgvyc4zrdq Full suite: 121 suites / 2774 tests green.
494 lines
18 KiB
JavaScript
494 lines
18 KiB
JavaScript
/**
|
|
* Share store — DC-053.
|
|
*
|
|
* Signed share tokens that let the host share a service with non-authenticated
|
|
* visitors. Two flavors:
|
|
*
|
|
* 1. **Public share links** — anonymous-readable preview URLs. Visitor sees
|
|
* a service card + status; no auth required. Host sets a TTL (1h / 24h /
|
|
* 7d). Optional email subscribe to receive status-change notifications.
|
|
*
|
|
* 2. **Tailscale-mediated share** — a one-shot Tailscale pre-auth key scoped
|
|
* to a device tag. Invitee clicks the link → device joins the tailnet →
|
|
* Caddy forward_auth inducts them into the service. Single-use, 24h TTL.
|
|
*
|
|
* Storage: data/shares.json. Atomic durable writes via the canonical
|
|
* shared atomic-write util (DC-099/DC-102) — fsync'd tmp+rename. The on-disk
|
|
* shape is identical to the invite store — UUID-keyed map of records with
|
|
* SHA-256 hashed tokens. Raw token is only returned at issue() time.
|
|
*
|
|
* Public-share token also carries a HMAC signature binding it to the
|
|
* serviceId so a leaked token cannot be silently retargeted. The signature
|
|
* is verified at peek() time using a server-side secret (licenseManager's
|
|
* install secret if available, otherwise a derived per-store key).
|
|
*
|
|
* Lifecycle:
|
|
* - issuePublic({ serviceId, ttlMs, createdBy }) → { id, token, url, expiresAt }
|
|
* - issueTailscale({ serviceId, email, ttlMs, createdBy }) → { id, token, url, expiresAt, authKeyId }
|
|
* - peek(token) → { kind, serviceId, expiresAt, remainingUses, usedAt? } | null
|
|
* - recordUse(token, { kind: 'public-subscribe' }) → { ok, count } | { ok: false, reason }
|
|
* - revoke(id) → boolean
|
|
* - list() → outstanding shares (admin view)
|
|
* - listForService(serviceId) → outstanding shares for a specific service
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const platformPaths = require('../../platform-paths');
|
|
const { atomicWriteFile, atomicWriteJSON } = require('../utils/atomic-write');
|
|
|
|
const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
const MAX_PUBLIC_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
|
|
const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]);
|
|
const TAILSCALE_MAX_USES = 1;
|
|
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
|
|
|
// DC-083: Public share endpoint input bounds. The two CSRF-exempt public
|
|
// endpoints accept untrusted body fields — bound shape, length, charset so
|
|
// an attacker can't bloat data/shares.json, inject CRLF into fields that
|
|
// flow into Tailscale auth-key descriptions, or smuggle control chars into
|
|
// the on-disk store. See routes/share.js for the route-layer validation;
|
|
// these helpers are the defense-in-depth belt under the route's suspenders.
|
|
const PUBLIC_EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
const PUBLIC_EMAIL_MAX_LENGTH = 254; // RFC 5321 §4.5.3.1.3
|
|
const PUBLIC_DEVICE_ID_REGEX = /^[a-zA-Z0-9._:-]+$/;
|
|
const PUBLIC_DEVICE_ID_MIN_LENGTH = 1;
|
|
const PUBLIC_DEVICE_ID_MAX_LENGTH = 128;
|
|
|
|
function validatePublicEmail(raw) {
|
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_email' };
|
|
// Reject control chars / NUL / CR / LF before they can corrupt the on-disk
|
|
// JSON or be embedded in subsequent log lines. RFC 5321 forbids these in
|
|
// SMTP addresses; we mirror that at the API layer.
|
|
if (raw.length === 0 || raw.length > PUBLIC_EMAIL_MAX_LENGTH) {
|
|
return { ok: false, reason: 'invalid_email' };
|
|
}
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
|
// The local-part can technically contain `+`, `.`, `_`, `%`, `-`; the
|
|
// domain part must have at least one dot and a 2+ letter TLD. Reject
|
|
// quote-bracket forms (RFC 5321 obs-quote-text) — we don't accept them.
|
|
if (!PUBLIC_EMAIL_REGEX.test(raw)) return { ok: false, reason: 'invalid_email' };
|
|
// Block obvious shell-attachment characters that the regex doesn't catch.
|
|
if (/[<>{}|\\^`\s]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
|
return { ok: true, email: raw.toLowerCase() };
|
|
}
|
|
|
|
function validatePublicDeviceId(raw) {
|
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_device_id' };
|
|
if (raw.length < PUBLIC_DEVICE_ID_MIN_LENGTH || raw.length > PUBLIC_DEVICE_ID_MAX_LENGTH) {
|
|
return { ok: false, reason: 'invalid_device_id' };
|
|
}
|
|
// Tailscale machine IDs are base64url-with-hyphens; we accept a slightly
|
|
// broader charset (`._:-`) to also accommodate hostname-style IDs and
|
|
// Caddy's `forward_auth` device headers. Reject CR/LF/NUL/TAB explicitly
|
|
// so a smuggled control char can't break out of the Tailscale auth-key
|
|
// description string in routes/share.js:213.
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
|
if (!PUBLIC_DEVICE_ID_REGEX.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
|
return { ok: true, deviceId: raw };
|
|
}
|
|
|
|
function _nowMs() { return Date.now(); }
|
|
function _nowIso() { return new Date().toISOString(); }
|
|
|
|
function _readJSON(filePath, fallback) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') return fallback;
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function _defaultData() { return { shares: {} }; }
|
|
|
|
function _sha256(s) {
|
|
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
|
|
}
|
|
|
|
function _hmacSign(secret, payload) {
|
|
return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64url');
|
|
}
|
|
|
|
function createShareStore(opts = {}) {
|
|
// Defensive resolver mirrors user-store / invite-store.
|
|
const candidates = [
|
|
opts.dataDir,
|
|
opts.platformPaths && opts.platformPaths.dataDir,
|
|
platformPaths && platformPaths.dataDir,
|
|
];
|
|
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|
|
|| require('os').tmpdir();
|
|
const log = opts.log || { info() {}, warn() {}, error() {} };
|
|
|
|
const file = path.join(dataDir, 'shares.json');
|
|
|
|
// Server-side secret. Prefer an explicit install secret if provided so the
|
|
// signature can outlive a reinstall. Fall back to a random per-store key
|
|
// persisted in dataDir (rotated on next start if the file moves).
|
|
const _secretFile = path.join(dataDir, '.share-secret');
|
|
function _loadSecret() {
|
|
if (opts.signingSecret && typeof opts.signingSecret === 'string') {
|
|
return opts.signingSecret;
|
|
}
|
|
try {
|
|
const existing = fs.readFileSync(_secretFile, 'utf8').trim();
|
|
if (existing && existing.length >= 32) return existing;
|
|
} catch (_) { /* missing or unreadable — generate fresh */ }
|
|
const fresh = crypto.randomBytes(32).toString('base64url');
|
|
try {
|
|
// DC-102: canonical atomic write. A torn `.share-secret` write would
|
|
// silently rotate the signing key on next boot — invalidating every
|
|
// outstanding share signature (all peeks fail, links 404) — with no
|
|
// error anywhere. fsync'd tmp+rename guarantees the file is either the
|
|
// complete old secret or the complete new one.
|
|
atomicWriteFile(_secretFile, fresh + '\n');
|
|
} catch (err) {
|
|
log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message });
|
|
}
|
|
return fresh;
|
|
}
|
|
const signingSecret = _loadSecret();
|
|
|
|
let _mutex = Promise.resolve();
|
|
function _enqueue(fn) {
|
|
const next = _mutex.then(fn, fn);
|
|
_mutex = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
function _load() {
|
|
const data = _readJSON(file, _defaultData());
|
|
if (!data.shares || typeof data.shares !== 'object') data.shares = {};
|
|
return data;
|
|
}
|
|
function _save(data) { atomicWriteJSON(file, data); }
|
|
|
|
function _prune(data) {
|
|
const cutoff = _nowMs() - PRUNE_AFTER_MS;
|
|
for (const id of Object.keys(data.shares)) {
|
|
const s = data.shares[id];
|
|
if (!s) { delete data.shares[id]; continue; }
|
|
const isTerminal = (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP))
|
|
|| (s.kind === 'tailscale' && s.usedAt)
|
|
|| (s.expiresAt && new Date(s.expiresAt).getTime() < cutoff);
|
|
if (isTerminal) delete data.shares[id];
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function _findByHash(data, hash) {
|
|
for (const id of Object.keys(data.shares)) {
|
|
const s = data.shares[id];
|
|
if (s && s.hash === hash) return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function _verifySignature(s, token) {
|
|
if (!s.signature || !s.serviceId) return false;
|
|
const expected = _hmacSign(signingSecret, `${s.kind}:${s.id}:${s.serviceId}:${token}`);
|
|
// constant-time compare; both are base64url strings of equal length
|
|
const a = Buffer.from(s.signature);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length) return false;
|
|
return crypto.timingSafeEqual(a, b);
|
|
}
|
|
|
|
function _publicView(s) {
|
|
return {
|
|
kind: s.kind,
|
|
id: s.id,
|
|
serviceId: s.serviceId,
|
|
expiresAt: s.expiresAt,
|
|
createdAt: s.createdAt,
|
|
createdBy: s.createdBy,
|
|
usedAt: s.usedAt || null,
|
|
usedBy: s.usedBy || null,
|
|
remainingUses: s.kind === 'tailscale' ? (s.usedAt ? 0 : 1) : Infinity,
|
|
subscribeCount: s.subscribeCount || 0,
|
|
subscribeCap: s.subscribeCap || null,
|
|
};
|
|
}
|
|
|
|
function issuePublic({ serviceId, ttlMs = DEFAULT_PUBLIC_TTL_MS, createdBy = 'admin', subscribeCap } = {}) {
|
|
return _enqueue(() => {
|
|
if (typeof serviceId !== 'string' || !serviceId.trim()) {
|
|
return { ok: false, reason: 'invalid_service' };
|
|
}
|
|
// Clamp TTL to allowed set so share links can't outlive their visibility intent.
|
|
const effectiveTtl = ALLOWED_PUBLIC_TTLS.has(ttlMs) ? ttlMs : DEFAULT_PUBLIC_TTL_MS;
|
|
const id = crypto.randomUUID();
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const hash = _sha256(token);
|
|
const signature = _hmacSign(signingSecret, `public:${id}:${serviceId}:${token}`);
|
|
const createdAt = _nowIso();
|
|
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
|
|
const cap = Number.isInteger(subscribeCap) && subscribeCap > 0
|
|
? Math.min(subscribeCap, 10000)
|
|
: PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
|
|
|
const data = _load();
|
|
_prune(data);
|
|
data.shares[id] = {
|
|
id,
|
|
kind: 'public',
|
|
hash,
|
|
signature,
|
|
serviceId,
|
|
createdBy,
|
|
createdAt,
|
|
expiresAt,
|
|
ttlMs: effectiveTtl,
|
|
usedAt: null,
|
|
usedBy: null,
|
|
subscribeCount: 0,
|
|
subscribeCap: cap,
|
|
};
|
|
_save(data);
|
|
|
|
log.info && log.info('share', 'public share issued', {
|
|
id, serviceId, createdBy, ttlMs: effectiveTtl,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
id,
|
|
token,
|
|
signature,
|
|
kind: 'public',
|
|
serviceId,
|
|
expiresAt,
|
|
ttlMs: effectiveTtl,
|
|
urlPath: `/share/${token}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
function issueTailscale({ serviceId, email, ttlMs = DEFAULT_TAILSCALE_TTL_MS, createdBy = 'admin' } = {}) {
|
|
return _enqueue(() => {
|
|
if (typeof serviceId !== 'string' || !serviceId.trim()) {
|
|
return { ok: false, reason: 'invalid_service' };
|
|
}
|
|
if (typeof email !== 'string' || !email.includes('@')) {
|
|
return { ok: false, reason: 'invalid_email' };
|
|
}
|
|
// Tailscale pre-auth keys max at 90 days but our share-window is 24h.
|
|
const effectiveTtl = Math.max(60 * 1000, Math.min(ttlMs, MAX_TAILSCALE_TTL_MS));
|
|
const id = crypto.randomUUID();
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const hash = _sha256(token);
|
|
const signature = _hmacSign(signingSecret, `tailscale:${id}:${serviceId}:${token}`);
|
|
const createdAt = _nowIso();
|
|
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
|
|
|
|
const data = _load();
|
|
_prune(data);
|
|
data.shares[id] = {
|
|
id,
|
|
kind: 'tailscale',
|
|
hash,
|
|
signature,
|
|
serviceId,
|
|
email: email.toLowerCase().trim(),
|
|
createdBy,
|
|
createdAt,
|
|
expiresAt,
|
|
ttlMs: effectiveTtl,
|
|
usedAt: null,
|
|
usedBy: null,
|
|
// authKeyId + authKey are written by the route layer after calling
|
|
// tailscale-coord.createAuthKey(); peek() doesn't surface them.
|
|
authKeyId: null,
|
|
};
|
|
_save(data);
|
|
|
|
log.info && log.info('share', 'tailscale share issued', {
|
|
id, serviceId, email: email.toLowerCase().trim(), createdBy, ttlMs: effectiveTtl,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
id,
|
|
token,
|
|
signature,
|
|
kind: 'tailscale',
|
|
serviceId,
|
|
email: email.toLowerCase().trim(),
|
|
expiresAt,
|
|
ttlMs: effectiveTtl,
|
|
urlPath: `/share/${token}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
function attachAuthKey(id, authKeyId) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const s = data.shares[id];
|
|
if (!s) return { ok: false, reason: 'not_found' };
|
|
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
|
|
s.authKeyId = authKeyId;
|
|
_save(data);
|
|
return { ok: true };
|
|
});
|
|
}
|
|
|
|
function peek(token) {
|
|
if (!token || typeof token !== 'string') return null;
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const s = _findByHash(data, hash);
|
|
if (!s) return null;
|
|
if (!_verifySignature(s, token)) {
|
|
log.warn && log.warn('share', 'peek rejected: bad signature', { id: s.id });
|
|
return null;
|
|
}
|
|
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) return null;
|
|
if (s.kind === 'tailscale' && s.usedAt) return null;
|
|
if (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) {
|
|
return null;
|
|
}
|
|
return _publicView(s);
|
|
});
|
|
}
|
|
|
|
function getRaw(token) {
|
|
if (!token || typeof token !== 'string') return null;
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const s = _findByHash(data, hash);
|
|
if (!s) return null;
|
|
if (!_verifySignature(s, token)) return null;
|
|
return s;
|
|
});
|
|
}
|
|
|
|
function recordPublicSubscribe(token, { email } = {}) {
|
|
return _enqueue(() => {
|
|
// DC-083: validate the optional subscriber email at the store layer too.
|
|
// The route layer validates first; this is the defense-in-depth catch
|
|
// for direct callers (cron sweepers, internal jobs, future endpoints).
|
|
// `email` is OPT-IN — callers omitting it get the original behavior.
|
|
let normalizedEmail = null;
|
|
if (email !== undefined && email !== null) {
|
|
const v = validatePublicEmail(email);
|
|
if (!v.ok) return { ok: false, reason: v.reason };
|
|
normalizedEmail = v.email;
|
|
}
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const s = _findByHash(data, hash);
|
|
if (!s) return { ok: false, reason: 'not_found' };
|
|
if (s.kind !== 'public') return { ok: false, reason: 'wrong_kind' };
|
|
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
|
|
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
|
|
return { ok: false, reason: 'expired' };
|
|
}
|
|
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
|
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
|
s.subscribeCount += 1;
|
|
// DC-083: record the last submitting email (capped to 8 entries to
|
|
// bound the on-disk size). PII minimization — we keep only the hash
|
|
// + last 8 emails; full email log would grow unbounded.
|
|
if (normalizedEmail) {
|
|
if (!Array.isArray(s.subscriberEmails)) s.subscriberEmails = [];
|
|
s.subscriberEmails.push(normalizedEmail);
|
|
if (s.subscriberEmails.length > 8) {
|
|
s.subscriberEmails.splice(0, s.subscriberEmails.length - 8);
|
|
}
|
|
}
|
|
_save(data);
|
|
return { ok: true, count: s.subscribeCount, cap };
|
|
});
|
|
}
|
|
|
|
function recordTailscaleUse(token, { deviceId } = {}) {
|
|
return _enqueue(() => {
|
|
// DC-083: validate deviceId at the store layer. The pre-fix code
|
|
// accepted ANY string of any length, including control chars and
|
|
// CR/LF — which would flow into the Tailscale auth-key description
|
|
// (routes/share.js:213) and into the on-disk shares.json. Reject
|
|
// early so an attacker can't bloat the store or smuggle characters
|
|
// out of the Tailscale description field.
|
|
let normalizedDeviceId = 'unknown';
|
|
if (deviceId !== undefined && deviceId !== null) {
|
|
const v = validatePublicDeviceId(deviceId);
|
|
if (!v.ok) return { ok: false, reason: v.reason };
|
|
normalizedDeviceId = v.deviceId;
|
|
}
|
|
const data = _load();
|
|
const hash = _sha256(token);
|
|
const s = _findByHash(data, hash);
|
|
if (!s) return { ok: false, reason: 'not_found' };
|
|
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
|
|
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
|
|
if (s.usedAt) return { ok: false, reason: 'already_used' };
|
|
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
|
|
return { ok: false, reason: 'expired' };
|
|
}
|
|
s.usedAt = _nowIso();
|
|
s.usedBy = normalizedDeviceId;
|
|
_save(data);
|
|
return { ok: true, share: _publicView(s) };
|
|
});
|
|
}
|
|
|
|
function revoke(id) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
if (!data.shares[id]) return false;
|
|
delete data.shares[id];
|
|
_save(data);
|
|
log.info && log.info('share', 'share revoked', { id });
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function list() {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
_prune(data);
|
|
return Object.values(data.shares).map(_publicView);
|
|
});
|
|
}
|
|
|
|
function listForService(serviceId) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
_prune(data);
|
|
return Object.values(data.shares)
|
|
.filter(s => s.serviceId === serviceId)
|
|
.map(_publicView);
|
|
});
|
|
}
|
|
|
|
return {
|
|
issuePublic,
|
|
issueTailscale,
|
|
attachAuthKey,
|
|
peek,
|
|
getRaw,
|
|
recordPublicSubscribe,
|
|
recordTailscaleUse,
|
|
revoke,
|
|
list,
|
|
listForService,
|
|
// expose for tests
|
|
_signingSecret: signingSecret,
|
|
_file: file,
|
|
};
|
|
}
|
|
|
|
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId }; |