openvibers/openvibe.community Commits
Discord relay: a mapping may only name an allow-listed webhook variable (DISCORD_WEBHOOK_*, or exactly the names in DISCORD_RELAY_WEBHOOK_VARS). Staff could point the relay at the URL in any environment variable (an internal service's base URL) and learn which variables are set; such mappings are now refused when made, never sent to if already stored, and never reported as configured
Changes
@@@ -56,6 +56,8 @@ COMMUNITY_DB_PATH=./data/community.db# webhook URL — the URL itself never goes into the database.#DISCORD_RELAY_ENABLED=true#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>++# Variables a mapping may name: DISCORD_WEBHOOK_* unless this lists the exact names.++#DISCORD_RELAY_WEBHOOK_VARS=DISCORD_WEBHOOK_FEEDBACK,DISCORD_WEBHOOK_GENERAL#DISCORD_RELAY_POLL_MS=30000#DISCORD_RELAY_BACKOFF_MS=30000#DISCORD_RELAY_MAX_ATTEMPTS=6@@@ -271,7 +271,10 @@ with the title, an excerpt and a link to the thread to that mapping's Discord we- A mapping (`relay_mappings`) names the **environment variable** that holds the webhook URL(`webhook_url_ref`, e.g. `DISCORD_WEBHOOK_FEEDBACK`); the URL itself never enters the-- database or any API response. Put the variable in `/etc/openvibe/community.env`.++ database or any API response. Put the variable in `/etc/openvibe/community.env`. Only++ allow-listed names can be mapped or sent to: `DISCORD_WEBHOOK_*`, or exactly the names in++ `DISCORD_RELAY_WEBHOOK_VARS` when that is set — staff cannot point the relay at the URL in any++ other variable (an internal service's base URL).- One delivery per (thread, mapping) — the dedupe key in `relay_deliveries`. Network errors,timeouts, 5xx, 429 (its `retry_after` honoured) and an unset variable are retried withexponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour) up to@@@ -113,6 +113,7 @@ function createApp(opts = {}) {const relay = opts.relay || createDiscordRelay({db, config, enabled: config.discordRelay.enabled,pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,++ webhookVars: config.discordRelay.webhookVars,...(opts.relayOptions || {}),});const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });@@@ -67,5 +67,7 @@ module.exports = {pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,++ // The only variables a mapping may name (comma-separated exact names); unset = DISCORD_WEBHOOK_*.++ webhookVars: (process.env.DISCORD_RELAY_WEBHOOK_VARS || '').split(',').map((s) => s.trim()).filter(Boolean),},};@@@ -7,7 +7,7 @@* GET /deliveries?status=pending|delivered|failed&limit= what was sent, what is failing and why* POST /deliveries/:id/retry queue a failed delivery again* GET /mappings space → webhook variable name-- * POST /mappings { space, webhook_url_ref, enabled? } map a space (the URL stays in the env)++ * POST /mappings { space, webhook_url_ref, enabled? } map a space (the URL stays in the env; allow-listed names only)* PUT /mappings/:id { enabled }** Responses never contain a webhook URL, only the variable name and whether it is set.@@@ -47,6 +47,7 @@ function createRelayApi({ relay, db, viewers }) {if (!space) fail(400, 'relay.invalid_space', 'Unknown space');const ref = String(b.webhook_url_ref || '');if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');++ if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false }) };}, 201));router.put('/mappings/:id', jsonBody, run((req) => {@@@ -9,7 +9,10 @@** - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an* environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at-- * send time.++ * send time. Only allow-listed names: DISCORD_RELAY_WEBHOOK_VARS (exact names) when set, else++ * DISCORD_WEBHOOK_*. Staff can therefore never point the relay at the URL in some other++ * variable (an internal service's base URL), nor learn which other variables are set; a++ * mapping outside the list is refused when made and never sent.* - Loop prevention: a thread whose origin is 'discord' (a future inbound relay) is never* relayed out, checked both when queueing and when sending.* - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with@@@ -25,14 +28,19 @@ const { sqlTime, isoTime } = require('../http/v1');const { AI_DISPLAY_NAME } = require('../identity/authors');const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;++const WEBHOOK_VAR = /^DISCORD_WEBHOOK_[A-Z0-9_]+$/;const MAX_BACKOFF_MS = 60 * 60_000;const SEND_TIMEOUT_MS = 10_000;/** Discord markdown in names/titles shown as plain text. */const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');--function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now() } = {}) {++/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */++function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now(), webhookVars = null } = {}) {const base = (config.baseUrl || '').replace(/\/$/, '');++ const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;++ /** May a mapping name this variable? */++ const refAllowed = (name) => typeof name === 'string' && ENV_NAME.test(name) && (allowed ? allowed.has(name) : WEBHOOK_VAR.test(name));let timer = null;let draining = null;@@@ -100,7 +108,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });-- if (!ENV_NAME.test(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an environment variable name' });++ if (!refAllowed(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });const url = env[d.webhook_url_ref];if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });if (!/^https?:\/\//i.test(url)) return record(d, { ok: false, error: `${d.webhook_url_ref} is not an http(s) URL` });@@@ -147,12 +155,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl// ── admin (staff) ────────────────────────────────────────function shapeMapping(m) {-- return { id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: !!env[m.webhook_url_ref], enabled: !!m.enabled, created_at: isoTime(m.created_at) };++ return { id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: refAllowed(m.webhook_url_ref) && !!env[m.webhook_url_ref], enabled: !!m.enabled, created_at: isoTime(m.created_at) };}function listMappings() {return db.prepare('SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id ORDER BY m.id').all().map(shapeMapping);}function addMapping({ space_id, webhook_url_ref, enabled: on = true }) {++ if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', ?, ?)ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0);return shapeMapping(db.prepare("SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?").get(space_id, webhook_url_ref));@@@ -179,7 +188,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glreturn n;}-- return { enabled, enqueueThread, drain, start, stop, listMappings, addMapping, setMappingEnabled, listDeliveries, retry, ENV_NAME, message };++ return { enabled, enqueueThread, drain, start, stop, listMappings, addMapping, setMappingEnabled, listDeliveries, retry, ENV_NAME, refAllowed, message };}--module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME };++module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME, WEBHOOK_VAR };@@@ -225,6 +225,48 @@ function stubWebhook() {await t.close();});++ await check('staff can map only allow-listed webhook variables, never the URL in some other env var', async () => {++ const secretHits = [];++ const internal = { url: 'http://127.0.0.1:9/internal', hits: secretHits };++ const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/ok`, OV_MEDIA_INTERNAL_URL: internal.url, NETWORK_INTERNAL_URL: internal.url, DISCORD_WEBHOOKS_X: internal.url };++ const fetchSpy = (url, o) => { if (String(url).startsWith(internal.url)) secretHits.push(url); return fetch(url, o); };++ const t = await boot({ authority: 'community', appOpts: { relayOptions: { enabled: true, env, fetchImpl: fetchSpy }, forumLimits: { threads: { cooldownSec: 0 } } } });++ const net = t.network;++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });++ const samJwt = net.sign({ id: 9, subject_id: net.addUser({ network_user_id: 9, username: 'sam' }).subject_id, username: 'sam', role: 'user' });++ const call = (path, { method = 'GET', cookie, token, json } = {}) => t.get(path, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) }, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ const map = (ref, who = { cookie: adminJwt }) => call('/api/v1/relay/mappings', { method: 'POST', json: { space: 'general', webhook_url_ref: ref }, ...who });++ for (const ref of ['OV_MEDIA_INTERNAL_URL', 'NETWORK_INTERNAL_URL', 'DISCORD_WEBHOOKS_X', 'PATH', 'DISCORD_WEBHOOK_']) {++ const r = await map(ref);++ assert.strictEqual(r.status, 400, `${ref}: ${r.text}`);++ assert.strictEqual(r.json().code, 'relay.ref_not_allowed');++ const svc = await map(ref, { token: net.signService({ cap: ['community.comment.moderate'] }) });++ assert.strictEqual(svc.status, 400, `${ref} as a service`);++ }++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get().n, 0);++ assert.throws(() => t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);++ // A mapping that predates the allow-list (straight into the table) is never sent to, and++ // does not reveal whether that variable is set.++ const general = t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();++ t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(general.id);++ const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((m) => m.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');++ assert.strictEqual(listed.webhook_configured, false);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);++ await t.app.locals.relay.drain();++ assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');++ const d = t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();++ assert.strictEqual(d.status, 'failed');++ assert.match(d.last_error, /not an allowed webhook variable/);++ assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');++ await t.close();++ // DISCORD_RELAY_WEBHOOK_VARS narrows it to exact names.++ const { createDiscordRelay } = require('../server/relay/discord');++ const strict = createDiscordRelay({ db: t.db, env, webhookVars: ['DISCORD_WEBHOOK_GENERAL'] });++ assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_GENERAL'), true);++ assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_OTHER'), false);++ assert.strictEqual(createDiscordRelay({ db: t.db, env }).refAllowed('DISCORD_WEBHOOK_OTHER'), true);++ });++await hook.close();done();})();
@@ -56,6 +56,8 @@ COMMUNITY_DB_PATH=./data/community.db
@@ -56,6 +56,8 @@ COMMUNITY_DB_PATH=./data/community.db
# webhook URL — the URL itself never goes into the database.
# webhook URL — the URL itself never goes into the database.
#DISCORD_RELAY_ENABLED=true
#DISCORD_RELAY_ENABLED=true
#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>
#DISCORD_WEBHOOK_FEEDBACK=https://discord.com/api/webhooks/<id>/<token>
+# Variables a mapping may name: DISCORD_WEBHOOK_* unless this lists the exact names.
+#DISCORD_RELAY_WEBHOOK_VARS=DISCORD_WEBHOOK_FEEDBACK,DISCORD_WEBHOOK_GENERAL
#DISCORD_RELAY_POLL_MS=30000
#DISCORD_RELAY_POLL_MS=30000
#DISCORD_RELAY_BACKOFF_MS=30000
#DISCORD_RELAY_BACKOFF_MS=30000
#DISCORD_RELAY_MAX_ATTEMPTS=6
#DISCORD_RELAY_MAX_ATTEMPTS=6
@@ -271,7 +271,10 @@ with the title, an excerpt and a link to the thread to that mapping's Discord we
@@ -271,7 +271,10 @@ with the title, an excerpt and a link to the thread to that mapping's Discord we
- A mapping (`relay_mappings`) names the **environment variable** that holds the webhook URL
- A mapping (`relay_mappings`) names the **environment variable** that holds the webhook URL
(`webhook_url_ref`, e.g. `DISCORD_WEBHOOK_FEEDBACK`); the URL itself never enters the
(`webhook_url_ref`, e.g. `DISCORD_WEBHOOK_FEEDBACK`); the URL itself never enters the
- database or any API response. Put the variable in `/etc/openvibe/community.env`.
+ database or any API response. Put the variable in `/etc/openvibe/community.env`. Only
+ allow-listed names can be mapped or sent to: `DISCORD_WEBHOOK_*`, or exactly the names in
+ `DISCORD_RELAY_WEBHOOK_VARS` when that is set — staff cannot point the relay at the URL in any
+ other variable (an internal service's base URL).
- One delivery per (thread, mapping) — the dedupe key in `relay_deliveries`. Network errors,
- One delivery per (thread, mapping) — the dedupe key in `relay_deliveries`. Network errors,
timeouts, 5xx, 429 (its `retry_after` honoured) and an unset variable are retried with
timeouts, 5xx, 429 (its `retry_after` honoured) and an unset variable are retried with
exponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour) up to
exponential backoff (`DISCORD_RELAY_BACKOFF_MS` · 2^(attempt−1), at most an hour) up to
@@ -113,6 +113,7 @@ function createApp(opts = {}) {
@@ -113,6 +113,7 @@ function createApp(opts = {}) {
const relay = opts.relay || createDiscordRelay({
const relay = opts.relay || createDiscordRelay({
db, config, enabled: config.discordRelay.enabled,
db, config, enabled: config.discordRelay.enabled,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
pollMs: config.discordRelay.pollMs, baseMs: config.discordRelay.backoffMs, maxAttempts: config.discordRelay.maxAttempts,
+ webhookVars: config.discordRelay.webhookVars,
...(opts.relayOptions || {}),
...(opts.relayOptions || {}),
});
});
const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });
const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });
@@ -67,5 +67,7 @@ module.exports = {
@@ -67,5 +67,7 @@ module.exports = {
pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,
pollMs: parseInt(process.env.DISCORD_RELAY_POLL_MS, 10) || 30_000,
backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,
backoffMs: parseInt(process.env.DISCORD_RELAY_BACKOFF_MS, 10) || 30_000,
maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,
maxAttempts: parseInt(process.env.DISCORD_RELAY_MAX_ATTEMPTS, 10) || 6,
+ // The only variables a mapping may name (comma-separated exact names); unset = DISCORD_WEBHOOK_*.
+ webhookVars: (process.env.DISCORD_RELAY_WEBHOOK_VARS || '').split(',').map((s) => s.trim()).filter(Boolean),
},
},
};
};
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
* GET /deliveries?status=pending|delivered|failed&limit= what was sent, what is failing and why
* GET /deliveries?status=pending|delivered|failed&limit= what was sent, what is failing and why
* POST /deliveries/:id/retry queue a failed delivery again
* POST /deliveries/:id/retry queue a failed delivery again
* GET /mappings space → webhook variable name
* GET /mappings space → webhook variable name
- * POST /mappings { space, webhook_url_ref, enabled? } map a space (the URL stays in the env)
+ * POST /mappings { space, webhook_url_ref, enabled? } map a space (the URL stays in the env; allow-listed names only)
* PUT /mappings/:id { enabled }
* PUT /mappings/:id { enabled }
*
*
* Responses never contain a webhook URL, only the variable name and whether it is set.
* Responses never contain a webhook URL, only the variable name and whether it is set.
@@ -47,6 +47,7 @@ function createRelayApi({ relay, db, viewers }) {
@@ -47,6 +47,7 @@ function createRelayApi({ relay, db, viewers }) {
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
if (!space) fail(400, 'relay.invalid_space', 'Unknown space');
const ref = String(b.webhook_url_ref || '');
const ref = String(b.webhook_url_ref || '');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
if (!relay.ENV_NAME.test(ref)) fail(400, 'relay.invalid_ref', 'webhook_url_ref is the NAME of an environment variable (A-Z, 0-9, _), never the URL');
+ if (!relay.refAllowed(ref)) fail(400, 'relay.ref_not_allowed', 'webhook_url_ref must be an allowed webhook variable (DISCORD_RELAY_WEBHOOK_VARS, else DISCORD_WEBHOOK_*)');
return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false }) };
return { mapping: relay.addMapping({ space_id: space.id, webhook_url_ref: ref, enabled: b.enabled !== false }) };
}, 201));
}, 201));
router.put('/mappings/:id', jsonBody, run((req) => {
router.put('/mappings/:id', jsonBody, run((req) => {
@@ -9,7 +9,10 @@
@@ -9,7 +9,10 @@
*
*
* - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an
* - Secrets never live in the database: a mapping stores webhook_url_ref, the NAME of an
* environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at
* environment variable (e.g. DISCORD_WEBHOOK_FEEDBACK); the URL is read from process.env at
- * send time.
+ * send time. Only allow-listed names: DISCORD_RELAY_WEBHOOK_VARS (exact names) when set, else
+ * DISCORD_WEBHOOK_*. Staff can therefore never point the relay at the URL in some other
+ * variable (an internal service's base URL), nor learn which other variables are set; a
+ * mapping outside the list is refused when made and never sent.
* - Loop prevention: a thread whose origin is 'discord' (a future inbound relay) is never
* - Loop prevention: a thread whose origin is 'discord' (a future inbound relay) is never
* relayed out, checked both when queueing and when sending.
* relayed out, checked both when queueing and when sending.
* - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with
* - Retries: network errors, timeouts, 5xx, 429 and a missing webhook variable are retried with
@@ -25,14 +28,19 @@ const { sqlTime, isoTime } = require('../http/v1');
@@ -25,14 +28,19 @@ const { sqlTime, isoTime } = require('../http/v1');
const { AI_DISPLAY_NAME } = require('../identity/authors');
const { AI_DISPLAY_NAME } = require('../identity/authors');
const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
+const WEBHOOK_VAR = /^DISCORD_WEBHOOK_[A-Z0-9_]+$/;
const MAX_BACKOFF_MS = 60 * 60_000;
const MAX_BACKOFF_MS = 60 * 60_000;
const SEND_TIMEOUT_MS = 10_000;
const SEND_TIMEOUT_MS = 10_000;
/** Discord markdown in names/titles shown as plain text. */
/** Discord markdown in names/titles shown as plain text. */
const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
const escapeDiscord = (s) => String(s || '').replace(/([\\*_~`|>#[\]])/g, '\\$1').replace(/@/g, '@\u200b');
-function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now() } = {}) {
+/** webhookVars: exact allowed names, or null/empty for the DISCORD_WEBHOOK_* default. */
+function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = globalThis.fetch, enabled = false, baseMs = 30_000, maxAttempts = 6, pollMs = 30_000, now = () => Date.now(), webhookVars = null } = {}) {
const base = (config.baseUrl || '').replace(/\/$/, '');
const base = (config.baseUrl || '').replace(/\/$/, '');
+ const allowed = Array.isArray(webhookVars) && webhookVars.length ? new Set(webhookVars) : null;
+ /** May a mapping name this variable? */
+ const refAllowed = (name) => typeof name === 'string' && ENV_NAME.test(name) && (allowed ? allowed.has(name) : WEBHOOK_VAR.test(name));
let timer = null;
let timer = null;
let draining = null;
let draining = null;
@@ -100,7 +108,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -100,7 +108,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });
if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });
if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });
if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });
- if (!ENV_NAME.test(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an environment variable name' });
+ if (!refAllowed(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
const url = env[d.webhook_url_ref];
const url = env[d.webhook_url_ref];
if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });
if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });
if (!/^https?:\/\//i.test(url)) return record(d, { ok: false, error: `${d.webhook_url_ref} is not an http(s) URL` });
if (!/^https?:\/\//i.test(url)) return record(d, { ok: false, error: `${d.webhook_url_ref} is not an http(s) URL` });
@@ -147,12 +155,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -147,12 +155,13 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
// ── admin (staff) ────────────────────────────────────────
// ── admin (staff) ────────────────────────────────────────
function shapeMapping(m) {
function shapeMapping(m) {
- return { id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: !!env[m.webhook_url_ref], enabled: !!m.enabled, created_at: isoTime(m.created_at) };
+ return { id: m.id, space: m.space_slug, direction: m.direction, webhook_url_ref: m.webhook_url_ref, webhook_configured: refAllowed(m.webhook_url_ref) && !!env[m.webhook_url_ref], enabled: !!m.enabled, created_at: isoTime(m.created_at) };
}
}
function listMappings() {
function listMappings() {
return db.prepare('SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id ORDER BY m.id').all().map(shapeMapping);
return db.prepare('SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id ORDER BY m.id').all().map(shapeMapping);
}
}
function addMapping({ space_id, webhook_url_ref, enabled: on = true }) {
function addMapping({ space_id, webhook_url_ref, enabled: on = true }) {
+ if (!refAllowed(webhook_url_ref)) throw new Error(`${webhook_url_ref} is not an allowed webhook variable`);
db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', ?, ?)
db.prepare(`INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', ?, ?)
ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0);
ON CONFLICT(space_id, direction, webhook_url_ref) DO UPDATE SET enabled = excluded.enabled`).run(space_id, webhook_url_ref, on ? 1 : 0);
return shapeMapping(db.prepare("SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?").get(space_id, webhook_url_ref));
return shapeMapping(db.prepare("SELECT m.*, s.slug AS space_slug FROM relay_mappings m JOIN spaces s ON s.id = m.space_id WHERE m.space_id = ? AND m.direction = 'out' AND m.webhook_url_ref = ?").get(space_id, webhook_url_ref));
@@ -179,7 +188,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -179,7 +188,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
return n;
return n;
}
}
- return { enabled, enqueueThread, drain, start, stop, listMappings, addMapping, setMappingEnabled, listDeliveries, retry, ENV_NAME, message };
+ return { enabled, enqueueThread, drain, start, stop, listMappings, addMapping, setMappingEnabled, listDeliveries, retry, ENV_NAME, refAllowed, message };
}
}
-module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME };
+module.exports = { createDiscordRelay, escapeDiscord, ENV_NAME, WEBHOOK_VAR };
@@ -225,6 +225,48 @@ function stubWebhook() {
@@ -225,6 +225,48 @@ function stubWebhook() {
await t.close();
await t.close();
});
});
+ await check('staff can map only allow-listed webhook variables, never the URL in some other env var', async () => {
+ const secretHits = [];
+ const internal = { url: 'http://127.0.0.1:9/internal', hits: secretHits };
+ const env = { DISCORD_WEBHOOK_GENERAL: `${hook.url}/api/webhooks/1/ok`, OV_MEDIA_INTERNAL_URL: internal.url, NETWORK_INTERNAL_URL: internal.url, DISCORD_WEBHOOKS_X: internal.url };
+ const fetchSpy = (url, o) => { if (String(url).startsWith(internal.url)) secretHits.push(url); return fetch(url, o); };
+ const t = await boot({ authority: 'community', appOpts: { relayOptions: { enabled: true, env, fetchImpl: fetchSpy }, forumLimits: { threads: { cooldownSec: 0 } } } });
+ const net = t.network;
+ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', role: 'admin' });
+ const samJwt = net.sign({ id: 9, subject_id: net.addUser({ network_user_id: 9, username: 'sam' }).subject_id, username: 'sam', role: 'user' });
+ const call = (path, { method = 'GET', cookie, token, json } = {}) => t.get(path, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(json !== undefined ? { 'content-type': 'application/json' } : {}) }, body: json !== undefined ? JSON.stringify(json) : undefined, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ const map = (ref, who = { cookie: adminJwt }) => call('/api/v1/relay/mappings', { method: 'POST', json: { space: 'general', webhook_url_ref: ref }, ...who });
+ for (const ref of ['OV_MEDIA_INTERNAL_URL', 'NETWORK_INTERNAL_URL', 'DISCORD_WEBHOOKS_X', 'PATH', 'DISCORD_WEBHOOK_']) {
+ const r = await map(ref);
+ assert.strictEqual(r.status, 400, `${ref}: ${r.text}`);
+ assert.strictEqual(r.json().code, 'relay.ref_not_allowed');
+ const svc = await map(ref, { token: net.signService({ cap: ['community.comment.moderate'] }) });
+ assert.strictEqual(svc.status, 400, `${ref} as a service`);
+ }
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM relay_mappings').get().n, 0);
+ assert.throws(() => t.app.locals.relay.addMapping({ space_id: 1, webhook_url_ref: 'OV_MEDIA_INTERNAL_URL' }), /not an allowed webhook variable/);
+ // A mapping that predates the allow-list (straight into the table) is never sent to, and
+ // does not reveal whether that variable is set.
+ const general = t.db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get();
+ t.db.prepare("INSERT INTO relay_mappings (space_id, direction, webhook_url_ref, enabled) VALUES (?, 'out', 'OV_MEDIA_INTERNAL_URL', 1)").run(general.id);
+ const listed = (await call('/api/v1/relay/mappings', { cookie: adminJwt })).json().mappings.find((m) => m.webhook_url_ref === 'OV_MEDIA_INTERNAL_URL');
+ assert.strictEqual(listed.webhook_configured, false);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Would go inside', body: 'hi' } })).status, 201);
+ await t.app.locals.relay.drain();
+ assert.deepStrictEqual(secretHits, [], 'nothing was sent to the internal URL');
+ const d = t.db.prepare("SELECT d.status, d.last_error FROM relay_deliveries d JOIN relay_mappings m ON m.id = d.mapping_id WHERE m.webhook_url_ref = 'OV_MEDIA_INTERNAL_URL'").get();
+ assert.strictEqual(d.status, 'failed');
+ assert.match(d.last_error, /not an allowed webhook variable/);
+ assert.strictEqual((await map('DISCORD_WEBHOOK_GENERAL')).status, 201, 'the conventional names still work');
+ await t.close();
+ // DISCORD_RELAY_WEBHOOK_VARS narrows it to exact names.
+ const { createDiscordRelay } = require('../server/relay/discord');
+ const strict = createDiscordRelay({ db: t.db, env, webhookVars: ['DISCORD_WEBHOOK_GENERAL'] });
+ assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_GENERAL'), true);
+ assert.strictEqual(strict.refAllowed('DISCORD_WEBHOOK_OTHER'), false);
+ assert.strictEqual(createDiscordRelay({ db: t.db, env }).refAllowed('DISCORD_WEBHOOK_OTHER'), true);
+ });
+
await hook.close();
await hook.close();
done();
done();
})();
})();