diff --git a/docs/guide/standalone/options.md b/docs/guide/standalone/options.md index 85677e1d..5a7e4eca 100644 --- a/docs/guide/standalone/options.md +++ b/docs/guide/standalone/options.md @@ -45,6 +45,31 @@ If so, you can change the IP extraction logic to simply read from a header set i The `/siteverify` endpoint is intended for server-to-server use, so it's not ratelimited by default. +## Adaptive challenge count + +You can increase the number of proof-of-work challenges dynamically based on request frequency. This is configured per site key from the dashboard's **Configuration** tab under **Adaptive challenge count**. + +When enabled, you select a **time window** (1 minute to 1 hour) and define tiers at two levels: + +### Global tiers + +Increase the challenge count when the **total** number of challenge requests (across all IPs) exceeds a threshold within the time window. This is particularly effective against distributed attacks (botnets, proxy rotation) where each individual IP stays below per-IP limits. + +### Per-IP tiers + +Increase the challenge count when a **single IP** exceeds a request threshold within the time window. This targets repeat offenders without affecting legitimate users. + +When both global and per-IP tiers are configured, the **highest** resulting challenge count is used. The base challenge count (set in the **Main** section) is always the minimum. + +Example configuration: + +| Level | Min requests | Challenge count | +|---------|-------------|-----------------| +| Global | 100 | 150 | +| Global | 500 | 300 | +| Per-IP | 5 | 150 | +| Per-IP | 15 | 300 | + ## Redis / Valkey Cap Standalone uses Redis (or Valkey) for all data storage. Set the `REDIS_URL` environment variable to your Redis connection string. This defaults to `redis://localhost:6379`. diff --git a/standalone/public/assets/style.css b/standalone/public/assets/style.css index aafd5a93..99b61fe3 100644 --- a/standalone/public/assets/style.css +++ b/standalone/public/assets/style.css @@ -2445,6 +2445,32 @@ input[type="range"]::-moz-range-progress { opacity: 1; } +.adaptive-tier-row { + align-items: flex-end; +} + +.adaptive-tier-row .origin-remove-btn { + margin-bottom: 8px; +} + +.add-btn { + display: inline-flex; + align-items: center; + background: none; + border: 1px dashed var(--border); + color: var(--text-secondary); + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.add-btn:hover { + border-color: var(--blue); + color: var(--blue); +} + .header-checks { display: flex; flex-wrap: wrap; diff --git a/standalone/public/js/dashboard.js b/standalone/public/js/dashboard.js index e282aa22..6fc651c6 100644 --- a/standalone/public/js/dashboard.js +++ b/standalone/public/js/dashboard.js @@ -429,6 +429,65 @@ function renderKeyDetail() { +

Adaptive challenge count

+
+ + +
+
+
+
+ + +
+
+

Global tiers

+

Increase challenge count when total requests across all IPs exceed the threshold. Useful against distributed attacks.

+
+ ${(key.config.adaptiveChallengeCount?.globalTiers || []).map((tier, i) => ` +
+
+ + +
+
+ + +
+ +
`).join("")} +
+ +

Per-IP tiers

+

Increase challenge count when a single IP exceeds the request threshold within the time window.

+
+ ${(key.config.adaptiveChallengeCount?.tiers || []).map((tier, i) => ` +
+
+ + +
+
+ + +
+ +
`).join("")} +
+ +
@@ -627,6 +686,41 @@ function renderKeyDetail() { ].map((c) => c.value); } + function getAdaptiveConfig() { + const enabled = document.getElementById("cfgAdaptiveEnabled").checked; + const windowMs = parseInt(document.getElementById("cfgAdaptiveWindow").value, 10); + const tierRows = [...document.querySelectorAll("#adaptiveTiersList .adaptive-tier-row")]; + const tiers = tierRows.map((row) => ({ + minRequests: parseInt(row.querySelector(".adaptive-tier-min").value, 10) || 1, + challengeCount: parseInt(row.querySelector(".adaptive-tier-count").value, 10) || 80, + })); + const globalTierRows = [...document.querySelectorAll("#adaptiveGlobalTiersList .adaptive-tier-row")]; + const globalTiers = globalTierRows.map((row) => ({ + minRequests: parseInt(row.querySelector(".adaptive-tier-min").value, 10) || 1, + challengeCount: parseInt(row.querySelector(".adaptive-tier-count").value, 10) || 80, + })); + return { enabled, windowMs, tiers, globalTiers }; + } + + function tiersEqual(a, b) { + if ((a?.length || 0) !== (b?.length || 0)) return false; + for (let i = 0; i < (a?.length || 0); i++) { + if (a[i].minRequests !== b[i].minRequests) return false; + if (a[i].challengeCount !== b[i].challengeCount) return false; + } + return true; + } + + function adaptiveConfigEquals(a, b) { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.enabled !== b.enabled) return false; + if (a.windowMs !== b.windowMs) return false; + if (!tiersEqual(a.tiers, b.tiers)) return false; + if (!tiersEqual(a.globalTiers, b.globalTiers)) return false; + return true; + } + function checkMainDirty() { const name = document.getElementById("cfgName").value.trim(); const difficulty = parseInt(document.getElementById("cfgDifficulty").value, 10); @@ -634,13 +728,16 @@ function renderKeyDetail() { const instrumentation = document.getElementById("cfgInstrumentation").checked; const obfuscationLevel = parseInt(document.getElementById("cfgObfuscationLevel").value, 10); const blockAutomatedBrowsers = document.getElementById("cfgBlockAutomatedBrowsers").checked; + const adaptiveCurrent = getAdaptiveConfig(); + const adaptiveOriginal = key.config.adaptiveChallengeCount || { enabled: false, windowMs: 60000, tiers: [], globalTiers: [] }; const dirty = name !== key.name || difficulty !== key.config.difficulty || challengeCount !== key.config.challengeCount || instrumentation !== key.config.instrumentation || obfuscationLevel !== (key.config.obfuscationLevel ?? 5) || - blockAutomatedBrowsers !== key.config.blockAutomatedBrowsers; + blockAutomatedBrowsers !== key.config.blockAutomatedBrowsers || + !adaptiveConfigEquals(adaptiveCurrent, adaptiveOriginal); document.getElementById("saveMainConfigBtn").disabled = !dirty; } @@ -677,6 +774,88 @@ function renderKeyDetail() { document.getElementById(id)?.addEventListener("input", checkSecurityDirty); } + document.getElementById("cfgAdaptiveEnabled")?.addEventListener("change", function () { + document.getElementById("adaptiveConfigFields").style.display = this.checked ? "block" : "none"; + checkMainDirty(); + }); + document.getElementById("cfgAdaptiveWindow")?.addEventListener("input", checkMainDirty); + + function addAdaptiveTierRow(minRequests = "", challengeCount = "") { + const list = document.getElementById("adaptiveTiersList"); + const idx = list.querySelectorAll(".adaptive-tier-row").length; + const div = document.createElement("div"); + div.className = "edit-row adaptive-tier-row"; + div.dataset.tierIndex = idx; + div.innerHTML = ` +
+ + +
+
+ + +
+ `; + div.querySelector(".adaptive-tier-remove").addEventListener("click", () => { + div.remove(); + checkMainDirty(); + }); + div.querySelector(".adaptive-tier-min").addEventListener("input", checkMainDirty); + div.querySelector(".adaptive-tier-count").addEventListener("input", checkMainDirty); + list.appendChild(div); + checkMainDirty(); + } + + document.getElementById("addAdaptiveTierBtn")?.addEventListener("click", () => addAdaptiveTierRow()); + + document.querySelectorAll("#adaptiveTiersList .adaptive-tier-remove").forEach((btn) => { + btn.addEventListener("click", () => { + btn.closest(".adaptive-tier-row").remove(); + checkMainDirty(); + }); + }); + document.querySelectorAll("#adaptiveTiersList .adaptive-tier-min, #adaptiveTiersList .adaptive-tier-count").forEach((input) => { + input.addEventListener("input", checkMainDirty); + }); + + function addAdaptiveGlobalTierRow(minRequests = "", challengeCount = "") { + const list = document.getElementById("adaptiveGlobalTiersList"); + const idx = list.querySelectorAll(".adaptive-tier-row").length; + const div = document.createElement("div"); + div.className = "edit-row adaptive-tier-row"; + div.dataset.tierIndex = idx; + div.innerHTML = ` +
+ + +
+
+ + +
+ `; + div.querySelector(".adaptive-tier-remove").addEventListener("click", () => { + div.remove(); + checkMainDirty(); + }); + div.querySelector(".adaptive-tier-min").addEventListener("input", checkMainDirty); + div.querySelector(".adaptive-tier-count").addEventListener("input", checkMainDirty); + list.appendChild(div); + checkMainDirty(); + } + + document.getElementById("addAdaptiveGlobalTierBtn")?.addEventListener("click", () => addAdaptiveGlobalTierRow()); + + document.querySelectorAll("#adaptiveGlobalTiersList .adaptive-tier-remove").forEach((btn) => { + btn.addEventListener("click", () => { + btn.closest(".adaptive-tier-row").remove(); + checkMainDirty(); + }); + }); + document.querySelectorAll("#adaptiveGlobalTiersList .adaptive-tier-min, #adaptiveGlobalTiersList .adaptive-tier-count").forEach((input) => { + input.addEventListener("input", checkMainDirty); + }); + function ensureKeyCorsEmptyRow() { const entries = [...document.querySelectorAll("#keyCorsOriginsList .origin-entry")]; const empties = entries.filter((e) => !e.querySelector(".key-cors-origin-input").value.trim()); @@ -2152,6 +2331,26 @@ async function saveMainConfig() { const obfuscationLevel = parseInt(document.getElementById("cfgObfuscationLevel").value, 10); const blockAutomatedBrowsers = document.getElementById("cfgBlockAutomatedBrowsers").checked; + const adaptiveEnabled = document.getElementById("cfgAdaptiveEnabled").checked; + const adaptiveWindowMs = parseInt(document.getElementById("cfgAdaptiveWindow").value, 10); + const adaptiveTierRows = [...document.querySelectorAll("#adaptiveTiersList .adaptive-tier-row")]; + const adaptiveTiers = adaptiveTierRows + .map((row) => ({ + minRequests: parseInt(row.querySelector(".adaptive-tier-min").value, 10), + challengeCount: parseInt(row.querySelector(".adaptive-tier-count").value, 10), + })) + .filter((t) => t.minRequests > 0 && t.challengeCount > 0); + const adaptiveGlobalTierRows = [...document.querySelectorAll("#adaptiveGlobalTiersList .adaptive-tier-row")]; + const adaptiveGlobalTiers = adaptiveGlobalTierRows + .map((row) => ({ + minRequests: parseInt(row.querySelector(".adaptive-tier-min").value, 10), + challengeCount: parseInt(row.querySelector(".adaptive-tier-count").value, 10), + })) + .filter((t) => t.minRequests > 0 && t.challengeCount > 0); + const adaptiveChallengeCount = adaptiveEnabled + ? { enabled: true, windowMs: adaptiveWindowMs, tiers: adaptiveTiers, globalTiers: adaptiveGlobalTiers } + : null; + if (!name || difficulty < 1 || challengeCount < 1) { showModal( "Validation error", @@ -2161,6 +2360,15 @@ async function saveMainConfig() { return; } + if (adaptiveEnabled && adaptiveTiers.length === 0 && adaptiveGlobalTiers.length === 0) { + showModal( + "Validation error", + '', + ); + btn.disabled = false; + return; + } + const res = await api("PUT", `/keys/${selectedKey.siteKey}/config`, { name, difficulty, @@ -2168,6 +2376,7 @@ async function saveMainConfig() { instrumentation, obfuscationLevel, blockAutomatedBrowsers, + adaptiveChallengeCount, }); if (res.success) { @@ -2180,6 +2389,7 @@ async function saveMainConfig() { instrumentation, obfuscationLevel, blockAutomatedBrowsers, + adaptiveChallengeCount, }; renderKeysList(searchInput.value); } else { diff --git a/standalone/src/cap.js b/standalone/src/cap.js index 3b0b2330..e1141650 100644 --- a/standalone/src/cap.js +++ b/standalone/src/cap.js @@ -60,6 +60,51 @@ function getClientIp(request, srv) { const CHALLENGE_TTL_MS = 15 * 60 * 1000; // 15min const TOKEN_TTL_MS = 2 * 60 * 60 * 1000; // 2h +function matchTier(count, tiers) { + const sorted = [...tiers].sort((a, b) => b.minRequests - a.minRequests); + for (const tier of sorted) { + if (count >= tier.minRequests) { + return Math.min(tier.challengeCount, 500); + } + } + return null; +} + +function hashIp(ip, siteKey) { + return createHmac("sha256", siteKey).update(ip).digest("hex").slice(0, 16); +} + +async function adaptiveChallengeCount(ip, siteKey, baseCount, adaptiveConfig) { + if (!adaptiveConfig?.enabled) return baseCount; + + const windowMs = adaptiveConfig.windowMs || 60_000; + const windowSecs = Math.ceil(windowMs / 1000); + const window = Math.floor(Date.now() / windowMs); + + let perIpResult = null; + if (ip && adaptiveConfig.tiers?.length) { + const ipHash = hashIp(ip, siteKey); + const ipKey = `ac:${siteKey}:${ipHash}:${window}`; + const ipCount = Number(await db.incr(ipKey)); + if (ipCount === 1) { + await db.expire(ipKey, windowSecs + 1); + } + perIpResult = matchTier(ipCount, adaptiveConfig.tiers); + } + + let globalResult = null; + if (adaptiveConfig.globalTiers?.length) { + const globalKey = `ac:g:${siteKey}:${window}`; + const globalCount = Number(await db.incr(globalKey)); + if (globalCount === 1) { + await db.expire(globalKey, windowSecs + 1); + } + globalResult = matchTier(globalCount, adaptiveConfig.globalTiers); + } + + return Math.max(baseCount, perIpResult ?? baseCount, globalResult ?? baseCount); +} + const b64url = (buf) => (buf instanceof Uint8Array ? Buffer.from(buf) : Buffer.from(buf, "utf8")).toString("base64url"); @@ -435,7 +480,10 @@ export const capServer = new Elysia({ } } - const c = keyConfig.challengeCount ?? 80; + const baseCount = keyConfig.challengeCount ?? 80; + const adaptiveConfig = keyConfig.adaptiveChallengeCount ?? null; + + const c = await adaptiveChallengeCount(ip, params.siteKey, baseCount, adaptiveConfig); const s = keyConfig.saltSize ?? 32; const d = keyConfig.difficulty ?? 4; const expires = Date.now() + CHALLENGE_TTL_MS; diff --git a/standalone/src/server.js b/standalone/src/server.js index 10c14ad0..fd9fd4aa 100644 --- a/standalone/src/server.js +++ b/standalone/src/server.js @@ -410,6 +410,7 @@ export const server = new Elysia({ corsOrigins, blockNonBrowserUA, requiredHeaders, + adaptiveChallengeCount, } = body; const config = { @@ -438,6 +439,10 @@ export const server = new Elysia({ requiredHeaders !== undefined ? requiredHeaders : (existingConfig.requiredHeaders ?? null), + adaptiveChallengeCount: + adaptiveChallengeCount !== undefined + ? adaptiveChallengeCount + : (existingConfig.adaptiveChallengeCount ?? null), }; const currentName = await db.hget(`key:${params.siteKey}`, "name"); @@ -468,6 +473,31 @@ export const server = new Elysia({ corsOrigins: t.Optional(t.Union([t.Array(t.String()), t.Null()])), blockNonBrowserUA: t.Optional(t.Union([t.Boolean(), t.Null()])), requiredHeaders: t.Optional(t.Union([t.Array(t.String()), t.Null()])), + adaptiveChallengeCount: t.Optional( + t.Union([ + t.Object({ + enabled: t.Boolean(), + windowMs: t.Number({ minimum: 60000, maximum: 3600000 }), + tiers: t.Array( + t.Object({ + minRequests: t.Number({ minimum: 1, maximum: 100000 }), + challengeCount: t.Number({ minimum: 1, maximum: 500 }), + }), + { minItems: 0, maxItems: 20 }, + ), + globalTiers: t.Optional( + t.Array( + t.Object({ + minRequests: t.Number({ minimum: 1, maximum: 10000000 }), + challengeCount: t.Number({ minimum: 1, maximum: 500 }), + }), + { minItems: 0, maxItems: 20 }, + ), + ), + }), + t.Null(), + ]), + ), }), detail: { tags: ["Keys"],