fix: security and robustness improvements, add project docs

- Hash inputs in timingSafeEqual to prevent length leak side-channel
- Add quote escaping to escapeHtml for defense in depth
- Normalize chatId to Number in parseKvKey for type consistency
- Log Retry-After header on 429 rate limit responses
- Slim README to focused overview, move details to docs/
- Add docs/: system-architecture, setup-guide, feature-decisions
- Add documentation section and README guidelines to CLAUDE.md
This commit is contained in:
2026-04-09 09:27:45 +07:00
parent 923ad7a40e
commit 976a2594b7
9 changed files with 342 additions and 131 deletions

View File

@@ -1,10 +1,13 @@
/**
* Timing-safe string comparison using Web Crypto API
* Timing-safe string comparison using Web Crypto API.
* Hashes both inputs first so the comparison is always fixed-length (32 bytes),
* preventing attackers from probing the secret length via timing side-channels.
*/
export async function timingSafeEqual(a, b) {
const encoder = new TextEncoder();
const bufA = encoder.encode(a);
const bufB = encoder.encode(b);
if (bufA.byteLength !== bufB.byteLength) return false;
return crypto.subtle.timingSafeEqual(bufA, bufB);
const [hashA, hashB] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(a)),
crypto.subtle.digest("SHA-256", encoder.encode(b)),
]);
return crypto.subtle.timingSafeEqual(hashA, hashB);
}

View File

@@ -23,11 +23,11 @@ function parseKvKey(kvKey) {
const possibleThread = raw.slice(lastColon + 1);
if (/^\d+$/.test(possibleThread)) {
return {
chatId: raw.slice(0, lastColon),
chatId: Number(raw.slice(0, lastColon)),
threadId: parseInt(possibleThread, 10),
};
}
return { chatId: raw, threadId: null };
return { chatId: Number(raw), threadId: null };
}
/**

View File

@@ -42,7 +42,8 @@ export async function handleQueue(batch, env) {
removed++;
msg.ack();
} else if (res.status === 429) {
console.log("Queue: rate limited, retrying");
const retryAfter = res.headers.get("Retry-After");
console.log(`Queue: rate limited for ${chatId}, Retry-After: ${retryAfter ?? "unknown"}`);
retried++;
msg.retry();
} else {

View File

@@ -64,7 +64,7 @@ export function humanizeStatus(status) {
* Escape HTML special chars for Telegram's HTML parse mode
*/
export function escapeHtml(s) {
return s?.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") ?? "";
return s?.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") ?? "";
}
/**