mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
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:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user