diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 03839e748..f7d644362 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,6 +1,7 @@ import { getEventContext, HTTPError } from "../index.ts"; import type { H3EventContext, HTTPEvent, Middleware } from "../index.ts"; +import { randomJitter, timingSafeEqual } from "./internal/auth.ts"; type _BasicAuthOptions = { /** @@ -47,32 +48,40 @@ export async function requireBasicAuth( opts: BasicAuthOptions, ): Promise { if (!opts.validate && !opts.password) { - throw new Error( - "You must provide either a validate function or a password for basic auth.", - ); + throw new HTTPError({ + message: "Either 'password' or 'validate' option must be provided", + status: 500, + }); } const authHeader = event.req.headers.get("authorization"); if (!authHeader) { - throw autheFailed(event); + throw authFailed(event); } const [authType, b64auth] = authHeader.split(" "); - if (authType !== "Basic" || !b64auth) { - throw autheFailed(event, opts?.realm); + if (!b64auth || authType.toLowerCase() !== "basic") { + throw authFailed(event, opts?.realm); } - const [username, password] = atob(b64auth).split(":"); + let authDecoded: string; + try { + authDecoded = atob(b64auth); + } catch { + throw authFailed(event, opts?.realm); + } + const colonIndex = authDecoded.indexOf(":"); + const username = authDecoded.slice(0, colonIndex); + const password = authDecoded.slice(colonIndex + 1); if (!username || !password) { - throw autheFailed(event, opts?.realm); + throw authFailed(event, opts?.realm); } - if (opts.username && username !== opts.username) { - throw autheFailed(event, opts?.realm); - } - if (opts.password && password !== opts.password) { - throw autheFailed(event, opts?.realm); - } - if (opts.validate && !(await opts.validate(username, password))) { - throw autheFailed(event, opts?.realm); + if ( + (opts.username && !timingSafeEqual(username, opts.username)) || + (opts.password && !timingSafeEqual(password, opts.password)) || + (opts.validate && !(await opts.validate(username, password))) + ) { + await randomJitter(); + throw authFailed(event, opts?.realm); } const context = getEventContext(event); @@ -97,7 +106,7 @@ export function basicAuth(opts: BasicAuthOptions): Middleware { }; } -function autheFailed(event: HTTPEvent, realm: string = "") { +function authFailed(event: HTTPEvent, realm: string = "") { return new HTTPError({ status: 401, statusText: "Authentication required", diff --git a/src/utils/internal/auth.ts b/src/utils/internal/auth.ts new file mode 100644 index 000000000..88cd68e6b --- /dev/null +++ b/src/utils/internal/auth.ts @@ -0,0 +1,29 @@ +const _textEncoder = new TextEncoder(); + +/** + * Constant-time string comparison to prevent timing attacks. + * Uses UTF-8 byte comparison for proper Unicode handling. + * Always compares all bytes regardless of where differences occur. + */ +export function timingSafeEqual(a: string, b: string): boolean { + const aBuf = _textEncoder.encode(a); + const bBuf = _textEncoder.encode(b); + const aLen = aBuf.length; + const bLen = bBuf.length; + // Always compare against the longer buffer length to avoid length-based timing leaks + const len = Math.max(aLen, bLen); + let result = aLen === bLen ? 0 : 1; + for (let i = 0; i < len; i++) { + // Use bitwise XOR to compare bytes; accumulate differences with OR + result |= (aBuf[i % aLen] ?? 0) ^ (bBuf[i % bLen] ?? 0); + } + return result === 0; +} + +/** + * Add random delay (0-100ms) to prevent timing-based credential inference. + */ +export function randomJitter(): Promise { + const jitter = Math.floor(Math.random() * 100); + return new Promise((resolve) => setTimeout(resolve, jitter)); +} diff --git a/test/auth.test.ts b/test/auth.test.ts index 2975c56d1..108dcbf01 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -38,4 +38,218 @@ describeMatrix("auth", (t, { it, expect }) => { expect(await result.text()).toBe("Hello, world!"); expect(result.status).toBe(200); }); + + it("handles password containing colons", async () => { + const authWithColon = basicAuth({ + username: "admin", + password: "pass:word:with:colons", + }); + t.app.get("/colon-test", () => "Success!", { middleware: [authWithColon] }); + + const result = await t.fetch("/colon-test", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("admin:pass:word:with:colons").toString("base64")}`, + }, + }); + + expect(await result.text()).toBe("Success!"); + expect(result.status).toBe(200); + }); + + it("rejects wrong password when password contains colons", async () => { + const authWithColon = basicAuth({ + username: "admin", + password: "pass:word:with:colons", + }); + t.app.get("/colon-reject", () => "Success!", { + middleware: [authWithColon], + }); + + const result = await t.fetch("/colon-reject", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("admin:pass:word").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 for invalid base64", async () => { + t.app.get("/invalid-base64", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/invalid-base64", { + method: "GET", + headers: { + Authorization: "Basic !!!invalid-base64!!!", + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when base64 value has no colon separator", async () => { + t.app.get("/no-colon", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/no-colon", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("usernameonly").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when username is empty", async () => { + t.app.get("/empty-username", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/empty-username", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from(":password").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when password is empty", async () => { + t.app.get("/empty-password", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/empty-password", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("username:").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when both username and password are empty", async () => { + t.app.get("/empty-both", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/empty-both", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from(":").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when auth type is not Basic", async () => { + t.app.get("/wrong-type", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/wrong-type", { + method: "GET", + headers: { + Authorization: "Bearer some-token", + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when auth header has no credentials part", async () => { + t.app.get("/no-credentials", () => "Hello, world!", { + middleware: [auth], + }); + const result = await t.fetch("/no-credentials", { + method: "GET", + headers: { + Authorization: "Basic", + }, + }); + + expect(result.status).toBe(401); + }); + + it("responds 401 when auth header is empty", async () => { + t.app.get("/empty-header", () => "Hello, world!", { middleware: [auth] }); + const result = await t.fetch("/empty-header", { + method: "GET", + headers: { + Authorization: "", + }, + }); + + expect(result.status).toBe(401); + }); + + it("supports custom validate function", async () => { + const customAuth = basicAuth({ + validate: (username, password) => { + return username === "custom" && password === "secret"; + }, + }); + t.app.get("/custom-validate", () => "Custom validated!", { + middleware: [customAuth], + }); + + const result = await t.fetch("/custom-validate", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("custom:secret").toString("base64")}`, + }, + }); + + expect(await result.text()).toBe("Custom validated!"); + expect(result.status).toBe(200); + }); + + it("rejects invalid credentials with custom validate function", async () => { + const customAuth = basicAuth({ + validate: (username, password) => { + return username === "custom" && password === "secret"; + }, + }); + t.app.get("/custom-validate-reject", () => "Custom validated!", { + middleware: [customAuth], + }); + + const result = await t.fetch("/custom-validate-reject", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("custom:wrong").toString("base64")}`, + }, + }); + + expect(result.status).toBe(401); + }); + + it("supports async custom validate function", async () => { + const asyncAuth = basicAuth({ + validate: async (username, password) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return username === "async" && password === "pass"; + }, + }); + t.app.get("/async-validate", () => "Async validated!", { + middleware: [asyncAuth], + }); + + const result = await t.fetch("/async-validate", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("async:pass").toString("base64")}`, + }, + }); + + expect(await result.text()).toBe("Async validated!"); + expect(result.status).toBe(200); + }); + + it("throws error when neither password nor validate is provided", async () => { + const invalidAuth = basicAuth({} as any); + t.app.get("/no-auth-config", () => "Should not reach!", { + middleware: [invalidAuth], + }); + + const result = await t.fetch("/no-auth-config", { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("user:pass").toString("base64")}`, + }, + }); + + expect(result.status).toBe(500); + }); }); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts new file mode 100644 index 000000000..307bcfdb2 --- /dev/null +++ b/test/unit/auth.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { timingSafeEqual } from "../../src/utils/internal/auth.ts"; + +describe("timingSafeEqual", () => { + it("returns true for equal ASCII strings", () => { + expect(timingSafeEqual("password", "password")).toBe(true); + expect(timingSafeEqual("test:123!", "test:123!")).toBe(true); + }); + + it("returns false for different ASCII strings", () => { + expect(timingSafeEqual("password", "Password")).toBe(false); + expect(timingSafeEqual("abc", "abd")).toBe(false); + }); + + it("returns false for different length strings", () => { + expect(timingSafeEqual("short", "longer")).toBe(false); + expect(timingSafeEqual("abc", "ab")).toBe(false); + }); + + it("returns true for equal empty strings", () => { + expect(timingSafeEqual("", "")).toBe(true); + }); + + it("returns false when comparing empty with non-empty", () => { + expect(timingSafeEqual("", "a")).toBe(false); + expect(timingSafeEqual("a", "")).toBe(false); + }); + + // UTF-8 / Unicode tests + it("returns true for equal strings with multi-byte UTF-8 characters", () => { + expect(timingSafeEqual("héllo", "héllo")).toBe(true); + expect(timingSafeEqual("日本語", "日本語")).toBe(true); + }); + + it("returns false for different multi-byte UTF-8 characters", () => { + expect(timingSafeEqual("héllo", "hello")).toBe(false); + expect(timingSafeEqual("日本語", "中文字")).toBe(false); + }); + + // Emojis and characters outside BMP are represented as surrogate pairs in UTF-16, + // but the implementation uses TextEncoder for proper UTF-8 byte comparison + it("returns true for equal strings with emoji (surrogate pairs)", () => { + expect(timingSafeEqual("pass😀word", "pass😀word")).toBe(true); + expect(timingSafeEqual("🔐secret🔐", "🔐secret🔐")).toBe(true); + }); + + it("returns false for different emoji strings", () => { + expect(timingSafeEqual("pass😀word", "pass😃word")).toBe(false); + expect(timingSafeEqual("🔐secret", "🔑secret")).toBe(false); + }); + + // Unicode normalization: NFC (composed) vs NFD (decomposed) forms look identical + // but have different byte sequences - this is expected behavior + describe("Unicode normalization edge cases", () => { + it("returns false for NFC vs NFD normalized strings (different byte sequences)", () => { + // 'é' can be represented as: + // - NFC (composed): U+00E9 (single code point) + // - NFD (decomposed): U+0065 U+0301 ('e' + combining acute accent) + const nfc = "caf\u00E9"; // café with composed é + const nfd = "cafe\u0301"; // café with decomposed é (e + combining accent) + + // These strings look identical when rendered but have different UTF-8 bytes. + // The implementation correctly identifies them as different. + expect(timingSafeEqual(nfc, nfd)).toBe(false); + + // If visual equality is needed, normalize both strings first + expect(timingSafeEqual(nfc.normalize("NFC"), nfd.normalize("NFC"))).toBe( + true, + ); + }); + + it("returns false for visually similar but different Unicode characters", () => { + // Greek question mark (U+037E) vs semicolon (U+003B) + // These look nearly identical in many fonts but are different bytes + const semicolon = "test;value"; + const greekQuestionMark = "test\u037Evalue"; + + expect(timingSafeEqual(semicolon, greekQuestionMark)).toBe(false); + }); + + it("returns false for strings with zero-width characters", () => { + const normal = "password"; + const withZeroWidth = "pass\u200Bword"; // zero-width space + + // These look identical but have different byte sequences + expect(timingSafeEqual(normal, withZeroWidth)).toBe(false); + }); + }); + + // Edge cases with the modulo operation when strings have different lengths + describe("length-related edge cases", () => { + it("returns false when one string is much longer", () => { + const short = "ab"; + const long = "abcdefghij"; + + expect(timingSafeEqual(short, long)).toBe(false); + }); + + it("handles the modulo wraparound correctly", () => { + // The implementation uses (i % aLen) and (i % bLen) for constant-time comparison, + // but length differences are tracked separately to ensure correct results + expect(timingSafeEqual("ab", "abab")).toBe(false); + expect(timingSafeEqual("abab", "ab")).toBe(false); + }); + }); + + // UTF-8 byte comparison tests - the implementation uses TextEncoder + describe("UTF-8 byte comparison", () => { + it("compares UTF-8 bytes, not UTF-16 code units", () => { + // The implementation uses TextEncoder to convert strings to UTF-8 bytes + // before comparison, ensuring proper Unicode handling. + + const encoder = new TextEncoder(); + + // Example: 'é' (U+00E9) is 1 UTF-16 code unit but 2 UTF-8 bytes + const e_acute = "é"; + expect(e_acute.length).toBe(1); // UTF-16 code units + expect(encoder.encode(e_acute).length).toBe(2); // UTF-8 bytes + + // Example: '😀' (U+1F600) is 2 UTF-16 code units but 4 UTF-8 bytes + const emoji = "😀"; + expect(emoji.length).toBe(2); // UTF-16 code units (surrogate pair) + expect(encoder.encode(emoji).length).toBe(4); // UTF-8 bytes + + // Example: '日' (U+65E5) is 1 UTF-16 code unit but 3 UTF-8 bytes + const kanji = "日"; + expect(kanji.length).toBe(1); // UTF-16 code units + expect(encoder.encode(kanji).length).toBe(3); // UTF-8 bytes + }); + + it("handles unpaired surrogates by converting to replacement character", () => { + // Unpaired surrogates are invalid UTF-8 but valid in JavaScript strings. + // TextEncoder converts them to the UTF-8 replacement character (U+FFFD). + + const emoji = "😀"; // Two UTF-16 code units: \uD83D\uDE00 + + const highSurrogate = emoji.charCodeAt(0); + const lowSurrogate = emoji.charCodeAt(1); + + expect(highSurrogate).toBe(0xd8_3d); + expect(lowSurrogate).toBe(0xde_00); + + // Create strings with unpaired surrogates + const invalidStr1 = String.fromCharCode(0xd8_3d); // lone high surrogate + const invalidStr2 = String.fromCharCode(0xd8_3d); // same + + // Same unpaired surrogates are equal (both become same replacement char) + expect(timingSafeEqual(invalidStr1, invalidStr2)).toBe(true); + + // Verify they encode to the UTF-8 replacement character + const encoder = new TextEncoder(); + const bytes1 = encoder.encode(invalidStr1); + const bytes2 = encoder.encode(invalidStr2); + + expect([...bytes1]).toEqual([0xef, 0xbf, 0xbd]); + expect([...bytes2]).toEqual([0xef, 0xbf, 0xbd]); + }); + + it("different invalid surrogates are equal in UTF-8 (both become replacement char)", () => { + // Two different unpaired surrogates both encode to the same + // UTF-8 replacement character, so they should be equal. + + const encoder = new TextEncoder(); + + const loneHigh1 = String.fromCharCode(0xd8_3d); // lone high surrogate + const loneHigh2 = String.fromCharCode(0xd8_3e); // different lone high surrogate + + // As UTF-8, they're the same (both become replacement character) + const bytes1 = encoder.encode(loneHigh1); + const bytes2 = encoder.encode(loneHigh2); + + expect([...bytes1]).toEqual([0xef, 0xbf, 0xbd]); + expect([...bytes2]).toEqual([0xef, 0xbf, 0xbd]); + + // With UTF-8 safe implementation, these are correctly identified as equal + expect(timingSafeEqual(loneHigh1, loneHigh2)).toBe(true); + }); + }); +});