Skip to content

Commit fb87cb1

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): per-org webhook endpoint rate limits and a spoof-proof per-IP limit
Webhook deliveries from a provider's shared IP addresses are no longer throttled by other customers' traffic or by a spoofed `X-Forwarded-For` header. The ingress per-IP limit used to key on the first `X-Forwarded-For` hop, which the client controls. Anyone could exhaust a provider IP's budget, so every customer's deliveries from that IP got 429, and rotating the header bypassed the limit. It also counted every request, so customers sharing a provider's IPs throttled each other. It now keys on the hop the load balancer appends, and it applies only to requests for endpoints that don't exist, so a source flooding the ingress with made-up endpoint ids gets 429 until the window resets. Requests to a real endpoint never count against an IP; they're bounded by that endpoint's own limit. Both ingest 429s (the per-IP and the per-endpoint limit) now include `Retry-After`. The per-endpoint request limit now comes from the organization's webhook limits instead of one global setting, so it can differ by plan and be raised for one organization. The env vars remain the default, which is what self-hosted installs use, and the limits page shows the effective value. Mono-RevId: 6ac705c9ece8fd8294bd69bd5b4daae413d0bf89
1 parent 598fdf2 commit fb87cb1

19 files changed

Lines changed: 473 additions & 57 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Each webhook endpoint's request rate limit now comes from the organization's plan and can be raised per organization, and the limits page shows it.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Webhook deliveries from a provider's shared IP addresses are no longer throttled by other customers' traffic or by a spoofed `X-Forwarded-For` header; the per-IP limit now applies only to requests for endpoints that don't exist, and every ingest 429 includes `Retry-After`.

‎apps/webapp/app/components/admin/backOffice/WebhookLimitsSection.server.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const OptionalRetention = z.preprocess(
1414
z.coerce.number().int().min(1).max(365).optional()
1515
);
1616

17+
const OptionalWindowSeconds = z.preprocess(
18+
(value) => (value === "" || value === null ? undefined : value),
19+
z.coerce.number().int().min(1).max(3600).optional()
20+
);
21+
1722
const OptionalFlag = z.preprocess(
1823
(value) => (value === "on" ? true : undefined),
1924
z.literal(true).optional()
@@ -26,6 +31,8 @@ const SetWebhookLimitsSchema = z.object({
2631
concurrency: OptionalLimit,
2732
deliveryRetentionDays: OptionalRetention,
2833
deliveryRetentionStrict: OptionalFlag,
34+
endpointRateLimitTokens: OptionalLimit,
35+
endpointRateLimitWindowSeconds: OptionalWindowSeconds,
2936
});
3037

3138
export type WebhookLimitsActionResult =

‎apps/webapp/app/components/admin/backOffice/WebhookLimitsSection.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ type NumericLimit =
2020
| "maxWaitersPerEnvironment"
2121
| "maxWaitersPerEndpoint"
2222
| "concurrency"
23-
| "deliveryRetentionDays";
23+
| "deliveryRetentionDays"
24+
| "endpointRateLimitTokens"
25+
| "endpointRateLimitWindowSeconds";
2426

2527
const FIELDS: Array<{ key: NumericLimit; label: string; hint: string }> = [
2628
{
@@ -43,6 +45,16 @@ const FIELDS: Array<{ key: NumericLimit; label: string; hint: string }> = [
4345
label: "Delivery retention (days)",
4446
hint: "Days of deliveries the org can see, 1 to 365. Older ones are hidden right away.",
4547
},
48+
{
49+
key: "endpointRateLimitTokens",
50+
label: "Endpoint rate limit (requests)",
51+
hint: "Requests one endpoint accepts per window. More get 429 with Retry-After.",
52+
},
53+
{
54+
key: "endpointRateLimitWindowSeconds",
55+
label: "Endpoint rate limit window (seconds)",
56+
hint: "The window the endpoint rate limit counts over, 1 to 3600 seconds.",
57+
},
4658
];
4759

4860
type Props = {

‎apps/webapp/app/entry.server.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,6 @@ export { engineRateLimiter } from "./services/engineRateLimit.server";
365365
export { otlpRateLimiter } from "./services/otlpRateLimit.server";
366366
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
367367
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
368-
export { webhookIngressIpRateLimiter } from "./services/webhookIngressIpRateLimit.server";
369368
export { socketIo } from "./v3/handleSocketIo.server";
370369
export { wss } from "./v3/handleWebsockets.server";
371370

‎apps/webapp/app/presenters/v3/LimitsPresenter.server.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export type QuotaInfo = {
4646
source: "default" | "plan" | "override";
4747
canExceed?: boolean;
4848
isUpgradable?: boolean;
49+
/** Shown after the limit, e.g. "per 10s" for a rate. */
50+
unit?: string;
4951
};
5052

5153
// Types for feature flags
@@ -78,6 +80,7 @@ export type LimitsResult = {
7880
webhookWaitersPerEndpoint: QuotaInfo | null;
7981
webhookConcurrency: QuotaInfo | null;
8082
webhookDeliveryRetentionDays: QuotaInfo | null;
83+
webhookEndpointRateLimit: QuotaInfo | null;
8184
};
8285
features: {
8386
hasStagingEnvironment: FeatureInfo;
@@ -422,6 +425,17 @@ export class LimitsPresenter extends BasePresenter {
422425
"Number of days of webhook deliveries you can see, including their bodies and headers.",
423426
0
424427
),
428+
webhookEndpointRateLimit: (() => {
429+
const quota = webhookQuota(
430+
"endpointRateLimitTokens",
431+
"Webhook endpoint rate limit",
432+
`Requests one webhook endpoint accepts every ${webhookLimits.limits.endpointRateLimitWindowSeconds.toLocaleString()} seconds. More get a 429 with Retry-After.`,
433+
0
434+
);
435+
return quota
436+
? { ...quota, unit: `per ${webhookLimits.limits.endpointRateLimitWindowSeconds}s` }
437+
: null;
438+
})(),
425439
},
426440
features: {
427441
hasStagingEnvironment: {

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ function QuotasSection({
551551
if (quotas.webhookWaitersPerEndpoint) quotaRows.push(quotas.webhookWaitersPerEndpoint);
552552
if (quotas.webhookConcurrency) quotaRows.push(quotas.webhookConcurrency);
553553
if (quotas.webhookDeliveryRetentionDays) quotaRows.push(quotas.webhookDeliveryRetentionDays);
554+
if (quotas.webhookEndpointRateLimit) quotaRows.push(quotas.webhookEndpointRateLimit);
554555

555556
const showSelfServe = useShowSelfServe();
556557

@@ -610,7 +611,8 @@ function QuotaRow({
610611
const isPerItemQuota =
611612
quota.name === "Charts per dashboard" ||
612613
quota.name === "Webhook waiters per endpoint" ||
613-
quota.name === "Webhook processing concurrency";
614+
quota.name === "Webhook processing concurrency" ||
615+
quota.name === "Webhook endpoint rate limit";
614616
const isRetentionQuota = isDurationQuota || isPerItemQuota;
615617
const isQueueSizeQuota = quota.name === "Max queued runs";
616618
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
@@ -712,7 +714,7 @@ function QuotaRow({
712714
{quota.limit !== null
713715
? isDurationQuota
714716
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
715-
: formatNumber(quota.limit)
717+
: `${formatNumber(quota.limit)}${quota.unit ? ` ${quota.unit}` : ""}`
716718
: "Unlimited"}
717719
</TableCell>
718720
<TableCell

‎apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { toWebhookHttpResponse, webhookHttpResponseFor } from "~/v3/webhookIngre
1010
* declares none. Never records a delivery.
1111
*/
1212
export async function loader({ request, params }: LoaderFunctionArgs) {
13-
const admitted = await admitWebhookIngress(params);
13+
const admitted = await admitWebhookIngress(params, request);
1414
if (admitted instanceof Response) return admitted;
1515
const { opaqueId } = admitted;
1616

@@ -28,7 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
2828
// signature scheme verifies. The engine resolves the endpoint (and its env id +
2929
// type) from the globally-unique opaqueId, so this route runs no env query.
3030
export async function action({ request, params }: ActionFunctionArgs) {
31-
const admitted = await admitWebhookIngress(params);
31+
const admitted = await admitWebhookIngress(params, request);
3232
if (admitted instanceof Response) return admitted;
3333
const { opaqueId } = admitted;
3434

‎apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId_.w.$waiterToken.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { toWebhookHttpResponse, webhookHttpResponseFor } from "~/v3/webhookIngre
1010
* verifier and can only complete that waiter; it never fans out to the endpoint's subscribers.
1111
*/
1212
export async function action({ request, params }: ActionFunctionArgs) {
13-
const admitted = await admitWebhookIngress(params);
13+
const admitted = await admitWebhookIngress(params, request);
1414
if (admitted instanceof Response) return admitted;
1515
const { opaqueId } = admitted;
1616

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,90 @@
1-
import type { RequestHandler } from "express";
21
import { Ratelimit } from "@upstash/ratelimit";
2+
import { LRUCache } from "lru-cache";
33
import { env } from "~/env.server";
4+
import type { RedisWithClusterOptions } from "~/redis.server";
5+
import { singleton } from "~/utils/singleton";
46
import { logger } from "./logger.server";
57
import { RateLimiter, type Duration } from "./rateLimiter.server";
68

7-
const ipLimiter = new RateLimiter({
8-
keyPrefix: "webhook-ingress-ip",
9-
limiter: Ratelimit.fixedWindow(
10-
env.WEBHOOK_INGRESS_IP_RATE_LIMIT_TOKENS,
11-
env.WEBHOOK_INGRESS_IP_RATE_LIMIT_WINDOW as Duration
12-
),
13-
});
9+
export type WebhookIngressIpLimitResult =
10+
| { success: true }
11+
| { success: false; retryAfterSeconds: number };
1412

15-
// Coarse per-IP gate mounted in server.ts ahead of the Remix handler. The
16-
// per-opaqueId limiter (webhookIngressRateLimit.server) is the real protection.
17-
export const webhookIngressIpRateLimiter: RequestHandler = async (req, res, next) => {
18-
if (!req.path.startsWith("/webhooks/v1/ingest/")) return next();
19-
const ip =
20-
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown";
21-
try {
22-
const { success } = await ipLimiter.limit(ip);
23-
if (!success) {
24-
res.status(429).json({ error: "Too many requests" });
25-
return;
26-
}
27-
} catch (error) {
28-
logger.warn("webhookIngressIpRateLimiter: limiter error, allowing request", { error });
29-
}
30-
next();
31-
};
13+
/**
14+
* Per-IP budget for ingress requests to endpoint ids that don't exist, so one source can't flood the
15+
* ingress with made-up ids. A request to a real endpoint never spends it: that endpoint's own limit
16+
* bounds it, so a provider IP shared by many customers is never throttled by another customer's
17+
* traffic. Callers pass the last X-Forwarded-For hop, which the load balancer appends; a request
18+
* without one isn't limited, and a limiter failure lets the request through. Each process remembers
19+
* an IP that used up its budget until the window resets, so `blockedFor` can refuse it before any
20+
* other work. A blocked IP can still reach a real endpoint the process hasn't cached yet: each
21+
* blocked IP gets its own small lookup budget (`takeBlockedLookup`), so made-up ids cost at most
22+
* that many database reads per second per source, and one source can't spend another's.
23+
*/
24+
export function createWebhookIngressIpRateLimiter(options: {
25+
tokens: number;
26+
window: Duration;
27+
redis?: RedisWithClusterOptions;
28+
blockedLookupsPerSecond?: number;
29+
}) {
30+
const limiter = new RateLimiter({
31+
keyPrefix: "webhook-ingress-ip",
32+
limiter: Ratelimit.fixedWindow(options.tokens, options.window),
33+
logFailure: false,
34+
redis: options.redis,
35+
});
36+
const blockedUntil = new LRUCache<string, number>({ max: 10_000 });
37+
const lookupsPerSecond = options.blockedLookupsPerSecond ?? 5;
38+
const lookupBuckets = new LRUCache<string, { tokens: number; refilledAt: number }>({
39+
max: 10_000,
40+
});
41+
42+
return {
43+
/** Whether this process may look up an uncached endpoint id for blocked `ip` right now. */
44+
takeBlockedLookup(ip: string): boolean {
45+
const now = Date.now();
46+
const bucket = lookupBuckets.get(ip) ?? { tokens: lookupsPerSecond, refilledAt: now };
47+
const tokens = Math.min(
48+
lookupsPerSecond,
49+
bucket.tokens + ((now - bucket.refilledAt) / 1000) * lookupsPerSecond
50+
);
51+
const taken = tokens >= 1;
52+
lookupBuckets.set(ip, { tokens: taken ? tokens - 1 : tokens, refilledAt: now });
53+
return taken;
54+
},
55+
/** Seconds until `ip` may send requests for unknown endpoints again, or undefined if it may now. */
56+
blockedFor(ip: string | null | undefined): number | undefined {
57+
if (!ip) return undefined;
58+
const until = blockedUntil.get(ip);
59+
if (until === undefined) return undefined;
60+
const now = Date.now();
61+
if (until <= now) {
62+
blockedUntil.delete(ip);
63+
return undefined;
64+
}
65+
return Math.max(1, Math.ceil((until - now) / 1000));
66+
},
67+
async limit(ip: string | null | undefined): Promise<WebhookIngressIpLimitResult> {
68+
if (!ip) return { success: true };
69+
try {
70+
const result = await limiter.limit(ip);
71+
if (result.success) return { success: true };
72+
blockedUntil.set(ip, result.reset);
73+
return {
74+
success: false,
75+
retryAfterSeconds: Math.max(1, Math.ceil((result.reset - Date.now()) / 1000)),
76+
};
77+
} catch (error) {
78+
logger.warn("webhookIngressIpRateLimiter: limiter error, allowing request", { error });
79+
return { success: true };
80+
}
81+
},
82+
};
83+
}
84+
85+
export const webhookIngressIpRateLimiter = singleton("webhookIngressIpRateLimiter", () =>
86+
createWebhookIngressIpRateLimiter({
87+
tokens: env.WEBHOOK_INGRESS_IP_RATE_LIMIT_TOKENS,
88+
window: env.WEBHOOK_INGRESS_IP_RATE_LIMIT_WINDOW as Duration,
89+
})
90+
);

0 commit comments

Comments
 (0)