webhook-relay / server.js
parsawit-deshwattanatham's picture
feat(api): add support for relaying to both prod and test
1220bb2
Raw History Blame Contribute Delete
25.4 kB
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`,
);
});