diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..b41e23b --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,7 @@ +## 2026-06-03 - Hardening equality checks against timing-based length leakage + +**Vulnerability:** Equality checks using `timingSafeEqual` or simple loops were leaking the length of the secret because they either performed a length check first or only iterated up to the length of the provided input. + +**Learning:** `timingSafeEqual` requires both buffers to have the exact same length. If an application checks `left.length === right.length` before calling it, it leaks the secret's length. Even a constant-time loop that only iterates up to the length of one of the inputs can leak the length of the other. + +**Prevention:** To prevent length leakage, always hash both the expected secret and the user-provided input with a fixed-length hashing algorithm (like SHA-256) before performing a constant-time comparison on the resulting hashes. This ensures the comparison is always performed on buffers of the same, fixed length. diff --git a/frontend/src/lib/middleware/auth.js b/frontend/src/lib/middleware/auth.js index cbcba5a..e10d8a2 100644 --- a/frontend/src/lib/middleware/auth.js +++ b/frontend/src/lib/middleware/auth.js @@ -21,9 +21,9 @@ function hashSecret(secret) { } export function safeEqual(a = '', b = '') { - const left = Buffer.from(a); - const right = Buffer.from(b); - return left.length === right.length && timingSafeEqual(left, right); + const leftHash = createHash('sha256').update(String(a)).digest(); + const rightHash = createHash('sha256').update(String(b)).digest(); + return timingSafeEqual(leftHash, rightHash); } export function createSessionCookieValue() { diff --git a/frontend/src/proxy.js b/frontend/src/proxy.js index 6744eaf..2e57277 100644 --- a/frontend/src/proxy.js +++ b/frontend/src/proxy.js @@ -10,7 +10,7 @@ export async function proxy(request) { const sessionCookie = request.cookies.get(SESSION_COOKIE)?.value || ''; if (secret && sessionCookie) { const expected = await hashSecret(secret); - if (safeEqualText(sessionCookie, expected)) return NextResponse.next(); + if (await safeEqualText(sessionCookie, expected)) return NextResponse.next(); } const loginUrl = request.nextUrl.clone(); @@ -34,11 +34,19 @@ async function hashSecret(secret) { return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); } -function safeEqualText(a = '', b = '') { - if (a.length !== b.length) return false; +async function safeEqualText(a = '', b = '') { + const aBytes = new TextEncoder().encode(String(a)); + const bBytes = new TextEncoder().encode(String(b)); + const [aHash, bHash] = await Promise.all([ + crypto.subtle.digest('SHA-256', aBytes), + crypto.subtle.digest('SHA-256', bBytes), + ]); + const aArr = new Uint8Array(aHash); + const bArr = new Uint8Array(bHash); + if (aArr.length !== bArr.length) return false; let mismatch = 0; - for (let i = 0; i < a.length; i += 1) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + for (let i = 0; i < aArr.length; i++) { + mismatch |= aArr[i] ^ bArr[i]; } return mismatch === 0; }