/** * 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 };