synology-chat: throttle webhook token guesses (#55141)

* synology-chat: throttle webhook token guesses

* synology-chat: keep valid webhook traffic within configured limits

* docs: refresh generated config baseline

* synology-chat: enforce lockout after repeated token failures
This commit is contained in:
Jacob Tomlinson
2026-03-26 08:30:06 -07:00
committed by GitHub
parent 9bc3d33b53
commit 0b4d073374
2 changed files with 225 additions and 1 deletions

View File

@@ -147,6 +147,125 @@ describe("createWebhookHandler", () => {
expect(res._status).toBe(401);
});
it("rate limits repeated invalid token guesses before the correct token can succeed", async () => {
const weakToken = "00000129";
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({
accountId: "weak-token-bruteforce-" + Date.now(),
token: weakToken,
rateLimitPerMinute: 5,
}),
deliver,
log,
});
let guessedToken: string | null = null;
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const candidate = String(i).padStart(8, "0");
const req = makeReq(
"POST",
makeFormBody({
token: candidate,
user_id: "123",
username: "testuser",
text: "Hello bot",
}),
);
(req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
const res = makeRes();
await handler(req, res);
if (res._status === 429) {
saw429 = true;
break;
}
if (res._status === 204) {
guessedToken = candidate;
break;
}
expect(res._status).toBe(401);
}
expect(saw429).toBe(true);
expect(guessedToken).toBeNull();
const lockedReq = makeReq(
"POST",
makeFormBody({
token: weakToken,
user_id: "123",
username: "testuser",
text: "Hello bot",
}),
);
(lockedReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
const lockedRes = makeRes();
await handler(lockedReq, lockedRes);
expect(lockedRes._status).toBe(429);
expect(deliver).not.toHaveBeenCalled();
});
it("keeps pre-auth throttling scoped to the remote IP", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({
accountId: "preauth-ip-scope-" + Date.now(),
rateLimitPerMinute: 1,
}),
deliver,
log,
});
const invalidReq = makeReq(
"POST",
makeFormBody({
token: "wrong-token",
user_id: "123",
username: "testuser",
text: "Hello",
}),
);
(invalidReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.10";
const invalidRes = makeRes();
await handler(invalidReq, invalidRes);
expect(invalidRes._status).toBe(401);
const validReq = makeReq("POST", validBody);
(validReq.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.11";
const validRes = makeRes();
await handler(validReq, validRes);
expect(validRes._status).toBe(204);
expect(deliver).toHaveBeenCalledTimes(1);
});
it("does not spend invalid-token budget on successful requests", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({
account: makeAccount({
accountId: "invalid-token-budget-" + Date.now(),
rateLimitPerMinute: 30,
}),
deliver,
log,
});
for (let i = 0; i < 11; i += 1) {
const req = makeReq("POST", validBody);
(req.socket as { remoteAddress?: string }).remoteAddress = "203.0.113.20";
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
}
expect(deliver).toHaveBeenCalledTimes(11);
});
it("accepts application/json with alias fields", async () => {
const deliver = vi.fn().mockResolvedValue(null);
const handler = createWebhookHandler({

View File

@@ -16,8 +16,77 @@ import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./type
// One rate limiter per account, created lazily
const rateLimiters = new Map<string, RateLimiter>();
const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();
const PREAUTH_MAX_BODY_BYTES = 64 * 1024;
const PREAUTH_BODY_TIMEOUT_MS = 5_000;
const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10;
const INVALID_TOKEN_WINDOW_MS = 60_000;
const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000;
type InvalidTokenRateLimitState = {
count: number;
windowStartMs: number;
};
class InvalidTokenRateLimiter {
private readonly limit: number;
private readonly state = new Map<string, InvalidTokenRateLimitState>();
constructor(limit: number) {
this.limit = limit;
}
private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined {
const existing = this.state.get(key);
if (!existing) {
return undefined;
}
if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) {
this.state.delete(key);
return undefined;
}
return existing;
}
private touch(key: string, value: InvalidTokenRateLimitState): void {
this.state.delete(key);
this.state.set(key, value);
while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) {
const oldestKey = this.state.keys().next().value;
if (!oldestKey) {
break;
}
this.state.delete(oldestKey);
}
}
isLocked(key: string, nowMs = Date.now()): boolean {
if (!key) {
return false;
}
const existing = this.normalizeState(key, nowMs);
return (existing?.count ?? 0) > this.limit;
}
recordFailure(key: string, nowMs = Date.now()): boolean {
if (!key) {
return false;
}
const existing = this.normalizeState(key, nowMs);
const nextCount = (existing?.count ?? 0) + 1;
const windowStartMs = existing?.windowStartMs ?? nowMs;
this.touch(key, { count: nextCount, windowStartMs });
return nextCount > this.limit;
}
clear(): void {
this.state.clear();
}
maxRequests(): number {
return this.limit;
}
}
function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
let rl = rateLimiters.get(account.accountId);
@@ -29,15 +98,34 @@ function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
return rl;
}
function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter {
const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE);
let rl = invalidTokenRateLimiters.get(account.accountId);
if (!rl || rl.maxRequests() !== limit) {
rl?.clear();
rl = new InvalidTokenRateLimiter(limit);
invalidTokenRateLimiters.set(account.accountId, rl);
}
return rl;
}
export function clearSynologyWebhookRateLimiterStateForTest(): void {
for (const limiter of rateLimiters.values()) {
limiter.clear();
}
rateLimiters.clear();
for (const limiter of invalidTokenRateLimiters.values()) {
limiter.clear();
}
invalidTokenRateLimiters.clear();
}
export function getSynologyWebhookRateLimiterCountForTest(): number {
return rateLimiters.size;
return rateLimiters.size + invalidTokenRateLimiters.size;
}
function getSynologyWebhookInvalidTokenRateLimitKey(req: IncomingMessage): string {
return req.socket?.remoteAddress ?? "unknown";
}
/** Read the full request body as a string. */
@@ -281,10 +369,22 @@ function authorizeSynologyWebhook(params: {
req: IncomingMessage;
account: ResolvedSynologyChatAccount;
payload: SynologyWebhookPayload;
invalidTokenRateLimiter: InvalidTokenRateLimiter;
rateLimiter: RateLimiter;
log?: WebhookHandlerDeps["log"];
}): SynologyWebhookAuthorization {
const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req);
// Once a source has exhausted its invalid-token budget, reject all requests in the window.
if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) {
params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
}
if (!validateToken(params.payload.token, params.account.token)) {
if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) {
params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
}
params.log?.warn(`Invalid token from ${params.req.socket?.remoteAddress}`);
return { ok: false, statusCode: 401, error: "Invalid token" };
}
@@ -313,6 +413,7 @@ function authorizeSynologyWebhook(params: {
}
if (!params.rateLimiter.check(params.payload.user_id)) {
// Keep a separate post-auth budget so authenticated users are still throttled per sender.
params.log?.warn(`Rate limit exceeded for user: ${params.payload.user_id}`);
return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
}
@@ -332,6 +433,7 @@ async function parseAndAuthorizeSynologyWebhook(params: {
req: IncomingMessage;
res: ServerResponse;
account: ResolvedSynologyChatAccount;
invalidTokenRateLimiter: InvalidTokenRateLimiter;
rateLimiter: RateLimiter;
log?: WebhookHandlerDeps["log"];
}): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> {
@@ -344,6 +446,7 @@ async function parseAndAuthorizeSynologyWebhook(params: {
req: params.req,
account: params.account,
payload: parsed.payload,
invalidTokenRateLimiter: params.invalidTokenRateLimiter,
rateLimiter: params.rateLimiter,
log: params.log,
});
@@ -453,6 +556,7 @@ async function processAuthorizedSynologyWebhook(params: {
export function createWebhookHandler(deps: WebhookHandlerDeps) {
const { account, deliver, log } = deps;
const rateLimiter = getRateLimiter(account);
const invalidTokenRateLimiter = getInvalidTokenRateLimiter(account);
return async (req: IncomingMessage, res: ServerResponse) => {
// Only accept POST
@@ -464,6 +568,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
req,
res,
account,
invalidTokenRateLimiter,
rateLimiter,
log,
});