// ── Push-notification fan-out (content-free tickles) ─────────────────────── // // The transport-agnostic publisher that turns an event into opt-in push // notifications. Two producers call in: // • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams // (beside the existing SSE broadcast — same event source, same allowlist). // • the admin create/publish-post path → publish('news.post', …). // // What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`, // no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy // endpoint. The app wakes and PULLS the real content over the authenticated, // ownership-checked API. So ntfy is treated as an untrusted relay: a leaked topic // reveals nothing, which is what lets it run with no per-user accounts // (docs/android/PLAN.md §11). // // SECURITY: a device `endpoint` is a client-supplied URL the server makes // server-side POSTs to — a classic SSRF vector. isAllowedEndpoint() gates every // registration AND every publish: HTTPS only, never a private/loopback host, and // (when configured) the origin must be in the shard's ntfy allow-set. const shardLinks = require('../model/shardLinks/shardLinks.model') const pushDevicesModel = require('../model/pushDevices/pushDevices.model') const { mapShardEvent } = require('../config/notificationStreams') const log = require('./logger')('push-dispatch') const TIMEOUT_MS = 5000 // Hosts that must never be POSTed to, even if the allow-set is empty (dev). This // is a coarse literal check (no DNS resolution) — the real protection in prod is // the configured allow-set below, which pins the single ntfy origin. const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|::1|fc00:|fd00:|172\.(1[6-9]|2\d|3[01])\.)/i function toOrigin(u) { try { return new URL(u).origin } catch { return null } } // Allowed publish origins, from NTFY_ALLOWED_ORIGINS (comma-separated) or, failing // that, NTFY_BASE_URL's origin. Empty when neither is set (dev fallback). function allowedOrigins() { const raw = process.env.NTFY_ALLOWED_ORIGINS || process.env.NTFY_BASE_URL || '' return raw .split(',') .map((s) => toOrigin(s.trim())) .filter(Boolean) } // Is this endpoint safe to POST to? HTTPS + non-private host + (if an allow-set is // configured) an allowed origin. With no allow-set (dev), any public HTTPS host is // permitted; the private-host check still blocks the obvious SSRF targets. function isAllowedEndpoint(endpoint) { let url try { url = new URL(String(endpoint)) } catch { return false } if (url.protocol !== 'https:') return false if (PRIVATE_HOST.test(url.hostname)) return false const allow = allowedOrigins() if (allow.length === 0) return true return allow.includes(url.origin) } async function postTickle(endpoint, bodyStr, deps) { const doFetch = deps.fetchImpl || fetch if (!isAllowedEndpoint(endpoint)) { log.warn('skipping push to disallowed endpoint', { endpoint }) return } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) try { const headers = { 'Content-Type': 'application/json' } const token = process.env.NTFY_PUBLISH_TOKEN if (token) headers.Authorization = `Bearer ${token}` await doFetch(endpoint, { method: 'POST', headers, body: bodyStr, signal: controller.signal }) } catch (err) { log.warn('push tickle failed', { message: err.message }) } finally { clearTimeout(timeout) } } // Publish one content-free tickle. Public (ownerUserId absent) → every device // whose user subscribes to the stream. Personal (ownerUserId set) → only that // user's devices, and only if subscribed. Never throws. async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) { const devices = deps.pushDevices || pushDevicesModel let rows try { rows = ownerUserId != null ? await devices.endpointsForUserStream(ownerUserId, streamId) : await devices.endpointsForStream(streamId) } catch (err) { log.warn('push endpoint lookup failed', { streamId, message: err.message }) return } if (!rows || rows.length === 0) return const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null }) await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) } // Fan a shard event out to push. Resolves personal (owner-keyed) targets to the // owning website user via shardLinks (an unlinked account → nobody to notify). // Never throws — a dead relay must never affect ingest. async function fromShardEvent(event, deps = {}) { const links = deps.shardLinks || shardLinks const targets = mapShardEvent(event, deps.tracker) for (const t of targets) { try { if (t.ownerAccount) { let owner = null try { owner = await links.getByAccount(t.ownerAccount) } catch { owner = null } if (!owner || owner.userId == null) continue await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps) } else { await publish(t.streamId, { ref: t.ref }, deps) } } catch (err) { log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message }) } } } module.exports = { publish, fromShardEvent, isAllowedEndpoint }