require("dotenv").config(); const express = require("express"); const helmet = require("helmet"); const rateLimit = require("express-rate-limit"); const morgan = require("morgan"); const crypto = require("crypto"); // ─── Config from environment ─────────────────────────────────────────────────── const { // Dual-URL support: set both to avoid restarting the container. // TARGET_WEBHOOK_URL is accepted as a fallback alias for PROD. TARGET_WEBHOOK_URL_PROD, TARGET_WEBHOOK_URL_TEST, TARGET_WEBHOOK_URL, // legacy fallback → treated as PROD TARGET_API_KEY, // Bearer token for the private HF Space RELAY_SECRET, // A secret token callers must include so only YOU can use this relay // SLACK_SIGNING_SECRET removed PORT = 7860, // HF Spaces default port LOG_LEVEL = "combined", DEFAULT_ENV = "prod", // 'prod', 'test', or 'both' – sets the initial active env // Security hardening (all optional) ALLOWED_IPS, // Comma-separated IPs allowed to call /relay & /admin. Empty = allow all. ALLOWED_ORIGINS, // Comma-separated origins for CORS on /relay & /admin. Empty = block all browser cross-origin. RATE_LIMIT_RELAY = "20", // Max /relay requests per minute per IP (default: 20) RATE_LIMIT_ADMIN = "10", // Max /admin requests per minute per IP (default: 10) } = process.env; // Resolve effective prod URL (explicit var wins over legacy alias) const PROD_URL = TARGET_WEBHOOK_URL_PROD || TARGET_WEBHOOK_URL || ""; const TEST_URL = TARGET_WEBHOOK_URL_TEST || ""; // Validate required env vars at startup if (!PROD_URL && !TEST_URL) { console.error( "❌ Missing required env var: set at least one of TARGET_WEBHOOK_URL_PROD or TARGET_WEBHOOK_URL_TEST", ); process.exit(1); } if (!TARGET_API_KEY) { console.error("❌ Missing required env var: TARGET_API_KEY"); process.exit(1); } if (!RELAY_SECRET) { console.error("❌ Missing required env var: RELAY_SECRET"); process.exit(1); } // ─── In-memory active environment (no restart needed to switch) ─────────────── // Valid values: 'prod' | 'test' // Switch via POST /admin/env or per-request via ?env= / X-Relay-Env header. let activeEnv = ["prod", "test", "both"].includes(DEFAULT_ENV) ? DEFAULT_ENV : "prod"; // ─── App setup ───────────────────────────────────────────────────────────────── const app = express(); // Trust proxy hops as configured via environment variable. If you run behind // one proxy (the common case for containers/load‑balancers) set this to `1`. A // value of `0` disables proxy trusting; higher numbers trust more hops. // // This helps express-rate-limit and other middleware get the real client IP // from X-Forwarded-For. See https://expressjs.com/en/guide/behind-proxies.html // for details. const TRUST_PROXY = process.env.TRUST_PROXY || "1"; app.set("trust proxy", TRUST_PROXY); // Security headers (hides "X-Powered-By" etc.) app.use(helmet()); // Rate limiting – prevent abuse on the public relay const limiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 60, // 60 requests per minute (global baseline) standardHeaders: true, legacyHeaders: false, message: { error: "Too many requests, slow down." }, }); app.use(limiter); // Stricter per-route limiters for sensitive endpoints const relayLimiter = rateLimit({ windowMs: 60 * 1000, max: parseInt(RATE_LIMIT_RELAY, 10) || 20, standardHeaders: true, legacyHeaders: false, message: { error: "Too many relay requests." }, }); const adminLimiter = rateLimit({ windowMs: 60 * 1000, max: parseInt(RATE_LIMIT_ADMIN, 10) || 10, standardHeaders: true, legacyHeaders: false, message: { error: "Too many admin requests." }, }); // Request logging – strip ?secret= from URLs so the relay secret never appears in logs. morgan.token("url-safe", (req) => (req.originalUrl || req.url || "").replace( /([?&])secret=[^&]*/gi, "$1secret=***", ), ); app.use( morgan( ':remote-addr - :remote-user [:date[clf]] ":method :url-safe HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', ), ); // Parse any content-type body so we can forward it as-is. app.use(express.json({ limit: "5mb" })); app.use(express.urlencoded({ extended: true, limit: "5mb" })); app.use(express.raw({ type: "*/*", limit: "5mb" })); // ─── Security middleware ────────────────────────────────────────────────────── // Optional IP allowlist – applied to /relay and /admin routes. // Set ALLOWED_IPS="1.2.3.4,5.6.7.8" in env. If empty, all IPs are allowed. const allowedIPs = ALLOWED_IPS ? ALLOWED_IPS.split(",") .map((ip) => ip.trim()) .filter(Boolean) : []; function ipAllowlist(req, res, next) { if (allowedIPs.length === 0) return next(); // not configured – allow all const clientIP = req.ip; if (allowedIPs.includes(clientIP)) return next(); console.warn(`[security] Blocked IP: ${clientIP}`); return res.status(403).json({ error: "Forbidden" }); } // CORS – applied to /relay and /admin routes. // Set ALLOWED_ORIGINS="https://mysite.com,https://other.com" in env. // If empty, browser cross-origin requests are blocked (non-browser callers are unaffected). const allowedOrigins = ALLOWED_ORIGINS ? ALLOWED_ORIGINS.split(",") .map((o) => o.trim()) .filter(Boolean) : []; function corsGuard(req, res, next) { const origin = req.headers.origin; if (!origin) return next(); // non-browser caller (curl, webhook services) – skip CORS if (allowedOrigins.length > 0 && allowedOrigins.includes(origin)) { res.set("Access-Control-Allow-Origin", origin); res.set("Vary", "Origin"); } else { // Origin not in allowlist – block browser cross-origin request return res.status(403).json({ error: "Origin not allowed" }); } res.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); res.set( "Access-Control-Allow-Headers", "Content-Type,X-Relay-Secret,X-Relay-Env,Authorization", ); if (req.method === "OPTIONS") return res.sendStatus(204); next(); } // ─── Helpers ─────────────────────────────────────────────────────────────────── /** * Resolves the effective env string for a request. * Priority: per-request ?env= / X-Relay-Env header > global activeEnv. */ function resolveTargetEnv(req) { const envParam = ( req.query.env || req.headers["x-relay-env"] || "" ).toLowerCase(); return ["prod", "test", "both"].includes(envParam) ? envParam : activeEnv; } /** * Returns an array of { env, url } objects for the resolved target(s). * When env is 'both', both prod and test URLs are included (if configured). */ function resolveTargetUrls(req) { const env = resolveTargetEnv(req); if (env === "both") { const urls = []; if (PROD_URL) urls.push({ env: "prod", url: PROD_URL }); if (TEST_URL) urls.push({ env: "test", url: TEST_URL }); return urls; } if (env === "test") { return [{ env: "test", url: TEST_URL || PROD_URL }]; // fall back to prod if test not set } return [{ env: "prod", url: PROD_URL || TEST_URL }]; // fall back to test if prod not set } /** * Redacts a URL for safe logging: shows the scheme and first 5 characters * of the host, then masks the rest with asterisks. */ function censorUrl(url) { if (!url) return "(not set)"; try { const u = new URL(url); const host = u.host; const visible = host.slice(0, 5); const masked = "*".repeat(Math.max(4, host.length - 5)); return `${u.protocol}//${visible}${masked}/`; } catch { // Not a valid URL – show first 5 chars then mask return url.slice(0, 5) + "****/"; } } /** * Constant-time comparison to prevent timing attacks on the secret. * Both sides are hashed to SHA-256 so the comparison is always over equal-length * buffers, eliminating the length-based timing side-channel. */ function verifySecret(provided) { if (!provided) return false; const a = crypto.createHash("sha256").update(provided).digest(); const b = crypto.createHash("sha256").update(RELAY_SECRET).digest(); return crypto.timingSafeEqual(a, b); } /** /** * Strips hop-by-hop and sensitive headers before forwarding. */ function sanitizeHeaders(incomingHeaders) { const skip = new Set([ "host", "authorization", "connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "proxy-authorization", "proxy-connection", "x-relay-secret", // never forward our own secret "x-relay-env", // never forward env-selection header "content-length", // will be recalculated "content-type", // set explicitly below to avoid duplicate headers ]); const out = {}; for (const [k, v] of Object.entries(incomingHeaders)) { if (!skip.has(k.toLowerCase())) out[k] = v; } return out; } // Permitted inbound Content-Types. Anything else is coerced to application/json // to prevent Content-Type injection into the upstream service. const ALLOWED_CONTENT_TYPES = new Set([ "application/json", "application/x-www-form-urlencoded", "text/plain", "text/xml", "application/xml", "application/octet-stream", "multipart/form-data", ]); // ─── Health check (public) ───────────────────────────────────────────────────── app.get("/", (_req, res) => { res.json({ status: "ok", service: "webhook-relay" }); }); app.get("/health", (_req, res) => { res.json({ status: "healthy" }); }); // ─── Admin: switch active env (protected by relay secret) ───────────────────── /** * GET /admin/env * Returns the current active environment and which URLs are configured. * Protected by X-Relay-Secret header or ?secret= query param. */ app.get("/admin/env", adminLimiter, ipAllowlist, corsGuard, (req, res) => { const secret = req.headers["x-relay-secret"] || req.query.secret; if (!verifySecret(secret)) return res.status(403).json({ error: "Forbidden" }); res.json({ activeEnv, configured: { prod: !!PROD_URL, test: !!TEST_URL, }, }); }); /** * POST /admin/env * Body: { "env": "test" } or { "env": "prod" } * Switches the global default target without restarting the container. * Protected by X-Relay-Secret header or ?secret= query param. */ app.post("/admin/env", adminLimiter, ipAllowlist, corsGuard, (req, res) => { const secret = req.headers["x-relay-secret"] || req.query.secret; if (!verifySecret(secret)) return res.status(403).json({ error: "Forbidden" }); const bodyObj = typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : {}; const requested = (bodyObj.env || "").toLowerCase(); if (requested === "test" && !TEST_URL) { return res .status(400) .json({ error: "TARGET_WEBHOOK_URL_TEST is not configured" }); } if (requested === "prod" && !PROD_URL) { return res .status(400) .json({ error: "TARGET_WEBHOOK_URL_PROD is not configured" }); } if (requested === "both" && (!PROD_URL || !TEST_URL)) { return res.status(400).json({ error: "Both TARGET_WEBHOOK_URL_PROD and TARGET_WEBHOOK_URL_TEST must be configured for 'both'", }); } if (!["prod", "test", "both"].includes(requested)) { return res .status(400) .json({ error: "env must be 'prod', 'test', or 'both'" }); } activeEnv = requested; console.log(`[admin] Active env switched to: ${activeEnv}`); res.json({ activeEnv }); }); // ─── Relay endpoint ──────────────────────────────────────────────────────────── app.all("/relay", relayLimiter, ipAllowlist, corsGuard, async (req, res) => { // ── 1. Authenticate the caller ── // Accept only a valid X-Relay-Secret header/query param const secret = req.headers["x-relay-secret"] || req.query.secret; if (!verifySecret(secret)) { // Generic 403 – intentionally vague so attackers learn nothing return res.status(403).json({ error: "Forbidden" }); } // ── 2. Handle Slack URL verification challenge ── const bodyObj = typeof req.body === "object" && !Buffer.isBuffer(req.body) ? req.body : null; if (bodyObj && bodyObj.type === "url_verification" && bodyObj.challenge) { return res.json({ challenge: bodyObj.challenge }); } // ── 3. Build the forwarded request ── try { const forwardHeaders = sanitizeHeaders(req.headers); // Attach the authentication header for the private HF Space forwardHeaders["Authorization"] = `Bearer ${TARGET_API_KEY}`; // Determine body to forward let body; const rawCT = req.headers["content-type"] || ""; const baseCT = rawCT.split(";")[0].trim().toLowerCase(); let contentType = ALLOWED_CONTENT_TYPES.has(baseCT) ? rawCT : "application/json"; if (Buffer.isBuffer(req.body)) { body = req.body; } else if (typeof req.body === "object" && req.body !== null) { body = JSON.stringify(req.body); contentType = "application/json"; } else if (req.body) { body = String(req.body); } forwardHeaders["Content-Type"] = contentType; const fetchOptions = { method: req.method, headers: forwardHeaders, }; if (body && !["GET", "HEAD"].includes(fetchOptions.method)) { fetchOptions.body = body; } // ── 4. Log received request ── console.log("\n─── [RELAY] Incoming request ───────────────────────────"); console.log(" Method :", req.method); console.log(" Path :", req.originalUrl); console.log( " Headers:", JSON.stringify( Object.fromEntries( Object.entries(req.headers) .filter( ([k]) => !["x-relay-secret", "x-relay-env"].includes(k.toLowerCase()), ) .map(([k, v]) => k.toLowerCase() === "authorization" ? [k, "Bearer ***"] : [k, v], ), ), null, 2, ), ); console.log( " Body :", body ? (Buffer.isBuffer(body) ? body.toString("utf8") : body) : "(none)", ); // ── 5. Fan out to all resolved target(s) ── const targets = resolveTargetUrls(req); if (targets.length === 0) { return res.status(500).json({ error: "No target URL configured" }); } const settled = await Promise.allSettled( targets.map(async ({ env: tEnv, url: tUrl }) => { const targetUrlObj = new URL(tUrl); for (const [k, v] of Object.entries(req.query)) { if (k !== "secret" && k !== "env") targetUrlObj.searchParams.append(k, v); } const targetUrlFinal = targetUrlObj.toString(); console.log( `\n─── [RELAY] Forwarding to ${tEnv} ────────────────────────────────────`, ); console.log(" Target :", censorUrl(targetUrlFinal)); console.log(" Method :", fetchOptions.method); console.log( " Headers:", JSON.stringify( Object.fromEntries( Object.entries(forwardHeaders).map(([k, v]) => k.toLowerCase() === "authorization" ? [k, "Bearer ***"] : [k, v], ), ), null, 2, ), ); console.log( " Body :", fetchOptions.body ? Buffer.isBuffer(fetchOptions.body) ? fetchOptions.body.toString("utf8") : fetchOptions.body : "(none)", ); console.log( "────────────────────────────────────────────────────────\n", ); const controller = new AbortController(); const fetchTimer = setTimeout(() => controller.abort(), 30_000); try { const upstream = await fetch(targetUrlFinal, { ...fetchOptions, signal: controller.signal, }); const responseBody = await upstream.text(); return { tEnv, upstream, responseBody }; } finally { clearTimeout(fetchTimer); } }), ); // Log failures; return primary response (prod preferred, fallback to test) for (const r of settled) { if (r.status === "rejected") { console.error("[RELAY] Target failed:", r.reason?.message || r.reason); } } const primary = settled.find( (r) => r.status === "fulfilled" && r.value.tEnv === "prod", ) || settled.find((r) => r.status === "fulfilled"); if (!primary) { throw ( settled.find((r) => r.status === "rejected")?.reason || new Error("All targets failed") ); } const { upstream, responseBody } = primary.value; res.status(upstream.status); const upstreamCT = upstream.headers.get("content-type"); if (upstreamCT) res.set("Content-Type", upstreamCT); return res.send(responseBody); } catch (err) { console.error("Relay error:", err.message); return res .status(502) .json({ error: "Bad gateway – upstream unreachable" }); } }); // ─── Wildcard relay (optional: /relay/*) ──────────────────────────────────────── // Allows forwarding to sub-paths of the target, e.g. /relay/my-workflow app.all( "/relay/:path(*)", relayLimiter, ipAllowlist, corsGuard, async (req, res) => { const secret = req.headers["x-relay-secret"] || req.query.secret; if (!verifySecret(secret)) { return res.status(403).json({ error: "Forbidden" }); } // ...existing code... try { const subPath = req.params.path; // Guard against SSRF: reject protocol-relative or absolute URLs in the path. if (/^(https?:)?\/\//i.test(subPath)) { return res.status(400).json({ error: "Invalid path" }); } const forwardHeaders = sanitizeHeaders(req.headers); forwardHeaders["Authorization"] = `Bearer ${TARGET_API_KEY}`; let body; const rawCT2 = req.headers["content-type"] || ""; const baseCT2 = rawCT2.split(";")[0].trim().toLowerCase(); let contentType = ALLOWED_CONTENT_TYPES.has(baseCT2) ? rawCT2 : "application/json"; if (Buffer.isBuffer(req.body)) { body = req.body; } else if (typeof req.body === "object" && req.body !== null) { body = JSON.stringify(req.body); contentType = "application/json"; } else if (req.body) { body = String(req.body); } forwardHeaders["Content-Type"] = contentType; const fetchOptions = { method: req.method, headers: forwardHeaders, }; if (body && !["GET", "HEAD"].includes(fetchOptions.method)) { fetchOptions.body = body; } // ── Log received request ── console.log("\n─── [RELAY/:path] Incoming request ─────────────────────"); console.log(" Method :", req.method); console.log(" Path :", req.originalUrl); console.log( " Headers:", JSON.stringify( Object.fromEntries( Object.entries(req.headers) .filter( ([k]) => !["x-relay-secret", "x-relay-env"].includes(k.toLowerCase()), ) .map(([k, v]) => k.toLowerCase() === "authorization" ? [k, "Bearer ***"] : [k, v], ), ), null, 2, ), ); console.log( " Body :", body ? Buffer.isBuffer(body) ? body.toString("utf8") : body : "(none)", ); // ── Fan out to all resolved target(s) ── const targetBases = resolveTargetUrls(req); if (targetBases.length === 0) { return res.status(500).json({ error: "No target URL configured" }); } const settled = await Promise.allSettled( targetBases.map(async ({ env: tEnv, url: tBase }) => { const targetUrl = new URL( subPath, tBase.endsWith("/") ? tBase : tBase + "/", ); for (const [k, v] of Object.entries(req.query)) { if (k !== "secret" && k !== "env") targetUrl.searchParams.append(k, v); } console.log( `\n─── [RELAY/:path] Forwarding to ${tEnv} ────────────────────────────────`, ); console.log(" Target :", censorUrl(targetUrl.toString())); console.log(" Method :", fetchOptions.method); console.log( " Headers:", JSON.stringify( Object.fromEntries( Object.entries(forwardHeaders).map(([k, v]) => k.toLowerCase() === "authorization" ? [k, "Bearer ***"] : [k, v], ), ), null, 2, ), ); console.log( " Body :", fetchOptions.body ? Buffer.isBuffer(fetchOptions.body) ? fetchOptions.body.toString("utf8") : fetchOptions.body : "(none)", ); console.log( "────────────────────────────────────────────────────────\n", ); const controller2 = new AbortController(); const fetchTimer2 = setTimeout(() => controller2.abort(), 30_000); try { const upstream = await fetch(targetUrl.toString(), { ...fetchOptions, signal: controller2.signal, }); const responseBody = await upstream.text(); return { tEnv, upstream, responseBody }; } finally { clearTimeout(fetchTimer2); } }), ); // Log failures; return primary response (prod preferred, fallback to test) for (const r of settled) { if (r.status === "rejected") { console.error( "[RELAY/:path] Target failed:", r.reason?.message || r.reason, ); } } const primary = settled.find( (r) => r.status === "fulfilled" && r.value.tEnv === "prod", ) || settled.find((r) => r.status === "fulfilled"); if (!primary) { throw ( settled.find((r) => r.status === "rejected")?.reason || new Error("All targets failed") ); } const { upstream, responseBody } = primary.value; res.status(upstream.status); const upstreamCT = upstream.headers.get("content-type"); if (upstreamCT) res.set("Content-Type", upstreamCT); return res.send(responseBody); } catch (err) { console.error("Relay error:", err.message); return res .status(502) .json({ error: "Bad gateway – upstream unreachable" }); } }, ); // ─── 404 for everything else ─────────────────────────────────────────────────── app.use((_req, res) => { res.status(404).json({ error: "Not found" }); }); // ─── Start ───────────────────────────────────────────────────────────────────── app.listen(PORT, "0.0.0.0", () => { console.log(`🚀 Webhook relay listening on port ${PORT}`); if (PROD_URL) console.log(`📡 PROD URL : ${censorUrl(PROD_URL)}`); if (TEST_URL) console.log(`🧪 TEST URL : ${censorUrl(TEST_URL)}`); console.log(`🎯 Active env: ${activeEnv}`); console.log( `💡 Switch env: POST /admin/env {"env":"test"|"prod"|"both"} with X-Relay-Secret`, ); console.log( `🔒 IP allowlist: ${allowedIPs.length > 0 ? allowedIPs.join(", ") : "disabled (all IPs allowed)"}`, ); console.log( `🌐 CORS origins: ${allowedOrigins.length > 0 ? allowedOrigins.join(", ") : "not set (browser cross-origin blocked)"}`, ); console.log( `⚡ Rate limits: relay=${RATE_LIMIT_RELAY}/min, admin=${RATE_LIMIT_ADMIN}/min, global=60/min`, ); });