diff --git a/changelog.d/features/12390-gamification-anti-cheat-award-path.md b/changelog.d/features/12390-gamification-anti-cheat-award-path.md new file mode 100644 index 0000000000..20a44e5b04 --- /dev/null +++ b/changelog.d/features/12390-gamification-anti-cheat-award-path.md @@ -0,0 +1 @@ +- **feat(gamification):** enforce the documented 1000 XP/min per-API-key anti-cheat rate limit on the XP award path; over-limit awards are logged and skipped instead of persisted, and the sliding window now matches the timestamp format stored in `xp_audit_log` ([#2403](https://github.com/diegosouzapw/OmniRoute/issues/2403)) diff --git a/src/lib/gamification/antiCheat.ts b/src/lib/gamification/antiCheat.ts index beba55ccf8..1ef7b41a46 100644 --- a/src/lib/gamification/antiCheat.ts +++ b/src/lib/gamification/antiCheat.ts @@ -140,13 +140,17 @@ async function computeZScore(apiKeyId: string): Promise { */ async function getRecentXp(apiKeyId: string, windowMs: number): Promise { const d = db(); - const since = new Date(Date.now() - windowMs).toISOString(); + // xp_audit_log.created_at is written by the table default datetime('now') as + // "YYYY-MM-DD HH:MM:SS", and TEXT compares are lexical. Computing the window start in + // SQLite keeps both sides in the same format (an ISO "T…Z" string from JS never matched + // same-day rows, so the window read as empty). + const windowStart = `-${Math.ceil(windowMs / 1000)} seconds`; const row = d .prepare( - "SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > ?" + "SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > datetime('now', ?)" ) - .get(apiKeyId, since) as { total: number }; + .get(apiKeyId, windowStart) as { total: number }; return row.total; } diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index cde799f5c0..9bd52a8d24 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -44,6 +44,16 @@ export async function emitGamificationEvent(params: { // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { + // Anti-cheat gate (#2403): the per-key 1000 XP/min rate limit and the z-score anomaly + // check run before anything is persisted. A rejected award is dropped and logged — the + // caller is fire-and-forget, so this must never throw. + const { validateScoreChange } = await import("./antiCheat"); + const verdict = await validateScoreChange(apiKeyId, action, xpAmount); + if (!verdict.allowed) { + log.warn("events.award_rejected", { apiKeyId, action, xpAmount, reason: verdict.reason }); + return; + } + const { addXp } = await import("../db/gamification"); addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined); diff --git a/tests/unit/gamification/antiCheat.test.ts b/tests/unit/gamification/antiCheat.test.ts index 36e46ef59c..e8b16e3653 100644 --- a/tests/unit/gamification/antiCheat.test.ts +++ b/tests/unit/gamification/antiCheat.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { validateScoreChange, getAnomalies } from "../../../src/lib/gamification/antiCheat"; +import { getDbInstance } from "../../../src/lib/db/core"; describe("Anti-Cheat", () => { describe("validateScoreChange", () => { @@ -14,6 +15,38 @@ describe("Anti-Cheat", () => { assert.equal(result.allowed, false); assert.ok(result.reason); }); + + // #2403: rows written through the table default (datetime('now'), "YYYY-MM-DD HH:MM:SS") + // must count toward the sliding window. Compares are lexical on TEXT, so the window + // boundary has to use the same format as the stored timestamps. + it("counts XP persisted inside the window toward the per-minute limit", async () => { + const db = getDbInstance(); + const key = `window-hit-${Date.now()}`; + db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run( + key, + "request", + 1000 + ); + + const result = await validateScoreChange(key, "request", 1); + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /Rate limit exceeded: 1001 > 1000 XP\/min/); + + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key); + }); + + it("ignores XP persisted before the window", async () => { + const db = getDbInstance(); + const key = `window-miss-${Date.now()}`; + db.prepare( + "INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', '-2 minutes'))" + ).run(key, "request", 1000); + + const result = await validateScoreChange(key, "request", 1); + assert.equal(result.allowed, true); + + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key); + }); }); describe("getAnomalies", () => { diff --git a/tests/unit/gamification/events.test.ts b/tests/unit/gamification/events.test.ts index 4b08c11607..0e2a9b6ed3 100644 --- a/tests/unit/gamification/events.test.ts +++ b/tests/unit/gamification/events.test.ts @@ -42,4 +42,83 @@ describe("Gamification Events", () => { // Cleanup db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey); }); + + // #2403: the per-key rate limit (1000 XP/min) documented for the anti-cheat layer must + // actually gate the award path. Each case seeds xp_audit_log directly so the window state + // is deterministic, then emits a 1 XP "request" event. + describe("anti-cheat gate on the award path", () => { + function seedXp(apiKeyId: string, xp: number, createdAtModifier?: string): void { + const db = getDbInstance(); + if (createdAtModifier) { + db.prepare( + "INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', ?))" + ).run(apiKeyId, "seed", xp, createdAtModifier); + } else { + db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run( + apiKeyId, + "seed", + xp + ); + } + } + + function countRequestRows(apiKeyId: string): number { + const row = getDbInstance() + .prepare( + "SELECT COUNT(*) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = 'request'" + ) + .get(apiKeyId) as { count: number }; + return row.count; + } + + function leaderboardScore(apiKeyId: string): number | undefined { + const row = getDbInstance() + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = 'global'") + .get(apiKeyId) as { score: number } | undefined; + return row?.score; + } + + function cleanup(apiKeyId: string): void { + const db = getDbInstance(); + db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId); + db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId); + } + + it("skips the award once the key has exhausted 1000 XP inside the last minute", async () => { + const key = `rate-limited-${Date.now()}`; + seedXp(key, 1000); + + await assert.doesNotReject(emitGamificationEvent({ apiKeyId: key, action: "request" })); + + assert.equal(countRequestRows(key), 0, "over-limit award must not be persisted"); + assert.equal( + leaderboardScore(key), + undefined, + "over-limit award must not reach the leaderboard" + ); + cleanup(key); + }); + + it("applies the award when the window total stays at or below the limit", async () => { + const key = `under-limit-${Date.now()}`; + seedXp(key, 999); // 999 + 1 == 1000, which is allowed (limit is exclusive of the cap) + + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + assert.equal(countRequestRows(key), 1); + assert.equal(leaderboardScore(key), 1); + cleanup(key); + }); + + it("ignores XP that was earned before the one-minute window", async () => { + const key = `stale-window-${Date.now()}`; + seedXp(key, 1000, "-2 minutes"); + + await emitGamificationEvent({ apiKeyId: key, action: "request" }); + + assert.equal(countRequestRows(key), 1, "stale XP must not block a fresh award"); + cleanup(key); + }); + }); });