diff --git a/src/endpoints/synology.ts b/src/endpoints/synology.ts index 8fbcc6b..15ea882 100644 --- a/src/endpoints/synology.ts +++ b/src/endpoints/synology.ts @@ -18,7 +18,15 @@ import type { AppContext, DdnsEnv } from "../types"; import { DDNS_RESPONSE, UPDATE_ACTION } from "../types"; import { performDdnsUpdates } from "../ddns"; import { logUpdate } from "../logging"; -import { isIpv4, isIpv6, parseAllowedHostnames, resolveUpdateHostnames } from "../validation"; +import { consumeRateLimit } from "../rate-limit"; +import { + isIpv4, + isIpv6, + parseAllowedHostnames, + parseRateLimitMaxRequests, + parseRateLimitWindowSeconds, + resolveUpdateHostnames, +} from "../validation"; import { z } from "zod"; const SynologyQuery = z.object({ @@ -61,6 +69,17 @@ function ddns(body: string, status = 200): Response { }); } +function ddnsRateLimited(retryAfterSeconds: number): Response { + return new Response(DDNS_RESPONSE.SERVER_ERROR, { + status: 429, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + "Retry-After": String(retryAfterSeconds), + }, + }); +} + /** * Handle `GET /nic/update` for Synology DSM custom DDNS providers. * @@ -104,6 +123,21 @@ export class SynologyUpdateEndpoint extends OpenAPIRoute { const env: DdnsEnv = c.env; try { + const clientIp = c.req.header("CF-Connecting-IP")?.trim(); + const maxRequests = parseRateLimitMaxRequests(env.DDNS_RATE_LIMIT_MAX_REQUESTS); + if (clientIp && maxRequests > 0) { + const rateLimit = await consumeRateLimit( + env.DB, + `ddns-update:${clientIp}`, + maxRequests, + parseRateLimitWindowSeconds(env.DDNS_RATE_LIMIT_WINDOW_SECONDS), + ); + + if (!rateLimit.allowed) { + return ddnsRateLimited(rateLimit.retryAfterSeconds); + } + } + const data = await this.getValidatedData(); const query = data.query as z.infer; const hostname = (query.hostname ?? "").trim().toLowerCase(); diff --git a/src/endpoints/update.ts b/src/endpoints/update.ts index d611114..0e38a4b 100644 --- a/src/endpoints/update.ts +++ b/src/endpoints/update.ts @@ -13,11 +13,14 @@ import { ApiException, contentJson, InputValidationException, OpenAPIRoute } fro import { z } from "zod"; import { performDdnsUpdates } from "../ddns"; import { logUpdate } from "../logging"; +import { consumeRateLimit } from "../rate-limit"; import { getDefaultHostname, isIpAddress, IpAddressSchema, parseAllowedHostnames, + parseRateLimitMaxRequests, + parseRateLimitWindowSeconds, resolveUpdateHostnames, } from "../validation"; @@ -93,6 +96,29 @@ export class UpdateEndpoint extends OpenAPIRoute { async handle(c: AppContext) { const env: DdnsEnv = c.env; + const clientIp = c.req.header("CF-Connecting-IP")?.trim(); + const maxRequests = parseRateLimitMaxRequests(env.DDNS_RATE_LIMIT_MAX_REQUESTS); + if (clientIp && maxRequests > 0) { + const rateLimit = await consumeRateLimit( + env.DB, + `ddns-update:${clientIp}`, + maxRequests, + parseRateLimitWindowSeconds(env.DDNS_RATE_LIMIT_WINDOW_SECONDS), + ); + + if (!rateLimit.allowed) { + return c.json( + { + success: false, + errors: [{ code: 4290, message: "Rate limit exceeded" }], + }, + 429, + { + "Retry-After": String(rateLimit.retryAfterSeconds), + }, + ); + } + } // Authenticate via header. const secret = c.req.header("X-DDNS-Secret"); diff --git a/src/index.ts b/src/index.ts index ef539aa..601ee95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import { HealthEndpoint } from "./endpoints/health"; import { SynologyUpdateEndpoint } from "./endpoints/synology"; import { UpdateEndpoint } from "./endpoints/update"; import { cleanupLogs } from "./logging"; +import { cleanupRateLimits } from "./rate-limit"; +import { parseRateLimitWindowSeconds } from "./validation"; // --------------------------------------------------------------------------- // App @@ -64,12 +66,20 @@ export default { ctx: ExecutionContext, ): Promise { const retentionDays = Number.parseInt(env.DDNS_LOG_RETENTION_DAYS ?? "30", 10) || 30; + const rateLimitWindowSeconds = parseRateLimitWindowSeconds(env.DDNS_RATE_LIMIT_WINDOW_SECONDS); ctx.waitUntil( - cleanupLogs(env.DB, retentionDays).then((deleted) => { - if (deleted > 0) { - console.log(`Cleaned up ${deleted} DDNS log rows older than ${retentionDays} days`); - } - }), + Promise.all([ + cleanupLogs(env.DB, retentionDays).then((deleted) => { + if (deleted > 0) { + console.log(`Cleaned up ${deleted} DDNS log rows older than ${retentionDays} days`); + } + }), + cleanupRateLimits(env.DB, rateLimitWindowSeconds).then((deleted) => { + if (deleted > 0) { + console.log(`Cleaned up ${deleted} stale DDNS rate limit rows`); + } + }), + ]), ); }, }; diff --git a/src/rate-limit.ts b/src/rate-limit.ts new file mode 100644 index 0000000..3e48038 --- /dev/null +++ b/src/rate-limit.ts @@ -0,0 +1,133 @@ +const RATE_LIMIT_SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS ddns_rate_limits ( + key TEXT PRIMARY KEY NOT NULL, + window_started_at INTEGER NOT NULL, + request_count INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + "CREATE INDEX IF NOT EXISTS idx_ddns_rate_limits_updated_at ON ddns_rate_limits (updated_at)", +] as const; + +interface RateLimitRow { + window_started_at: number; + request_count: number; +} + +export interface RateLimitDecision { + allowed: boolean; + remaining: number; + retryAfterSeconds: number; +} + +function isMissingRateLimitTableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("no such table: ddns_rate_limits"); +} + +async function ensureRateLimitSchema(db: D1Database): Promise { + for (const statement of RATE_LIMIT_SCHEMA_STATEMENTS) { + await db.prepare(statement).run(); + } +} + +async function readRateLimitRow(db: D1Database, key: string): Promise { + const { results } = await db + .prepare( + "SELECT window_started_at, request_count FROM ddns_rate_limits WHERE key = ? LIMIT 1", + ) + .bind(key) + .all(); + + return (results[0] as RateLimitRow | undefined) ?? null; +} + +async function writeRateLimitRow( + db: D1Database, + key: string, + windowStartedAt: number, + requestCount: number, + updatedAt: number, +): Promise { + await db + .prepare( + `INSERT INTO ddns_rate_limits (key, window_started_at, request_count, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + window_started_at = excluded.window_started_at, + request_count = excluded.request_count, + updated_at = excluded.updated_at`, + ) + .bind(key, windowStartedAt, requestCount, updatedAt) + .run(); +} + +async function consumeRateLimitInternal( + db: D1Database, + key: string, + maxRequests: number, + windowSeconds: number, + nowSeconds: number, +): Promise { + const existing = await readRateLimitRow(db, key); + const stale = !existing || nowSeconds - existing.window_started_at >= windowSeconds; + const windowStartedAt = stale ? nowSeconds : existing.window_started_at; + const requestCount = stale ? 1 : existing.request_count + 1; + + await writeRateLimitRow(db, key, windowStartedAt, requestCount, nowSeconds); + + const retryAfterSeconds = Math.max(1, windowStartedAt + windowSeconds - nowSeconds); + + return { + allowed: requestCount <= maxRequests, + remaining: Math.max(0, maxRequests - requestCount), + retryAfterSeconds, + }; +} + +export async function consumeRateLimit( + db: D1Database, + key: string, + maxRequests: number, + windowSeconds: number, +): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + + try { + return await consumeRateLimitInternal(db, key, maxRequests, windowSeconds, nowSeconds); + } catch (error) { + if (isMissingRateLimitTableError(error)) { + try { + await ensureRateLimitSchema(db); + return await consumeRateLimitInternal(db, key, maxRequests, windowSeconds, nowSeconds); + } catch (retryError) { + console.error("Failed to recreate DDNS rate limit schema:", retryError); + } + } + + console.error("Failed to apply DDNS rate limit:", error); + return { + allowed: true, + remaining: maxRequests, + retryAfterSeconds: windowSeconds, + }; + } +} + +export async function cleanupRateLimits(db: D1Database, windowSeconds: number): Promise { + const cutoff = Math.floor(Date.now() / 1000) - windowSeconds; + + try { + const result = await db + .prepare("DELETE FROM ddns_rate_limits WHERE updated_at < ?") + .bind(cutoff) + .run(); + return result.meta.changes ?? 0; + } catch (error) { + if (isMissingRateLimitTableError(error)) { + return 0; + } + + console.error("Failed to clean up DDNS rate limit rows:", error); + return 0; + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index a4f4502..1be1078 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,17 @@ export interface DdnsEnv extends Env { * cleanup job deletes them. Defaults to `"30"`. */ DDNS_LOG_RETENTION_DAYS: string; + /** + * Maximum number of update requests allowed per client IP within the + * current fixed window. Set to `"0"` to disable rate limiting. + * Defaults to `"10"`. + */ + DDNS_RATE_LIMIT_MAX_REQUESTS: string; + /** + * Fixed window size, in seconds, used for per-client DDNS rate limits. + * Defaults to `"60"`. + */ + DDNS_RATE_LIMIT_WINDOW_SECONDS: string; } /** Hono context typed with the DDNS environment bindings. */ diff --git a/src/validation.ts b/src/validation.ts index 6330e78..c5bda04 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -69,6 +69,28 @@ export function parseTtl(value: string | undefined): number { return ttl; } +function parseIntegerInRange( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < minimum) return minimum; + if (parsed > maximum) return maximum; + return parsed; +} + +export function parseRateLimitMaxRequests(value: string | undefined): number { + return parseIntegerInRange(value, 10, 0, 1000); +} + +export function parseRateLimitWindowSeconds(value: string | undefined): number { + return parseIntegerInRange(value, 60, 1, 3600); +} + /** * Split a comma-separated hostname list into a normalized array. * Each entry is trimmed and lowercased. Empty entries are dropped.