fix(mattermost): bound slash validation cache clocks

This commit is contained in:
Peter Steinberger
2026-05-30 11:22:25 -04:00
parent 8e90a1cad9
commit 20fbb8bd14
2 changed files with 153 additions and 6 deletions

View File

@@ -458,6 +458,119 @@ describe("slash-http", () => {
expect(client.requests).toEqual(["/commands/cmd-1"]);
});
it("does not cache failed command validation when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const payload = {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
} finally {
vi.useRealTimers();
}
});
it("drops exhausted validation lookup buckets when the current clock is invalid", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
try {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({ command });
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
for (let i = 0; i < 20; i += 1) {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
}
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
} finally {
dateNow.mockRestore();
}
expect(client.requests).toHaveLength(21);
} finally {
vi.useRealTimers();
}
});
it("scopes validation cache entries by account", async () => {
const registeredCommand = createRegisteredCommand();
const clientA = createCommandLookupClient({

View File

@@ -6,6 +6,10 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
@@ -209,8 +213,14 @@ export function clearMattermostSlashCommandValidationCacheForAccount(accountId:
}
function sweepCommandValidationFailureCache(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationFailureCache.clear();
return;
}
for (const [key, entry] of commandValidationFailureCache) {
if (entry.expiresAt <= now) {
const expiresAt = asDateTimestampMs(entry.expiresAt);
if (expiresAt === undefined || expiresAt <= validNow) {
commandValidationFailureCache.delete(key);
}
}
@@ -225,11 +235,16 @@ function sweepCommandValidationFailureCache(now = Date.now()): void {
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
sweepCommandValidationFailureCache(now);
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
return false;
}
const cached = commandValidationFailureCache.get(key);
if (!cached) {
return false;
}
if (cached.expiresAt > now) {
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (expiresAt !== undefined && expiresAt > validNow) {
return true;
}
commandValidationFailureCache.delete(key);
@@ -237,17 +252,31 @@ function hasCachedCommandValidationFailure(key: string, now = Date.now()): boole
}
function cacheCommandValidationFailure(key: string, accountId: string): void {
sweepCommandValidationFailureCache();
const now = Date.now();
sweepCommandValidationFailureCache(now);
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
nowMs: now,
});
if (expiresAt === undefined) {
commandValidationFailureCache.delete(key);
return;
}
commandValidationFailureCache.set(key, {
accountId,
expiresAt: Date.now() + COMMAND_VALIDATION_FAILURE_CACHE_MS,
expiresAt,
});
}
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationLookupRateLimit.clear();
return;
}
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
for (const [key, entry] of commandValidationLookupRateLimit) {
if (now - entry.updatedAt > staleAfterMs) {
const updatedAt = asDateTimestampMs(entry.updatedAt);
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
commandValidationLookupRateLimit.delete(key);
}
}
@@ -265,7 +294,12 @@ function reserveCommandValidationLookup(params: {
accountId: string;
now?: number;
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
const now = params.now ?? Date.now();
const rawNow = params.now ?? Date.now();
const now = asDateTimestampMs(rawNow);
if (now === undefined) {
commandValidationLookupRateLimit.clear();
return { allowed: true };
}
sweepCommandValidationLookupRateLimit(now);
const existing = commandValidationLookupRateLimit.get(params.key);
if (!existing) {