Skip to content

Commit 4e091a6

Browse files
authored
fix(basic-auth): use jitter and constant-time string comparison (#1283)
1 parent 5e10d8f commit 4e091a6

File tree

4 files changed

+448
-17
lines changed

4 files changed

+448
-17
lines changed

src/utils/auth.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getEventContext, HTTPError } from "../index.ts";
22

33
import type { H3EventContext, HTTPEvent, Middleware } from "../index.ts";
4+
import { randomJitter, timingSafeEqual } from "./internal/auth.ts";
45

56
type _BasicAuthOptions = {
67
/**
@@ -47,32 +48,40 @@ export async function requireBasicAuth(
4748
opts: BasicAuthOptions,
4849
): Promise<true> {
4950
if (!opts.validate && !opts.password) {
50-
throw new Error(
51-
"You must provide either a validate function or a password for basic auth.",
52-
);
51+
throw new HTTPError({
52+
message: "Either 'password' or 'validate' option must be provided",
53+
status: 500,
54+
});
5355
}
5456

5557
const authHeader = event.req.headers.get("authorization");
5658
if (!authHeader) {
57-
throw autheFailed(event);
59+
throw authFailed(event);
5860
}
5961
const [authType, b64auth] = authHeader.split(" ");
60-
if (authType !== "Basic" || !b64auth) {
61-
throw autheFailed(event, opts?.realm);
62+
if (!b64auth || authType.toLowerCase() !== "basic") {
63+
throw authFailed(event, opts?.realm);
6264
}
63-
const [username, password] = atob(b64auth).split(":");
65+
let authDecoded: string;
66+
try {
67+
authDecoded = atob(b64auth);
68+
} catch {
69+
throw authFailed(event, opts?.realm);
70+
}
71+
const colonIndex = authDecoded.indexOf(":");
72+
const username = authDecoded.slice(0, colonIndex);
73+
const password = authDecoded.slice(colonIndex + 1);
6474
if (!username || !password) {
65-
throw autheFailed(event, opts?.realm);
75+
throw authFailed(event, opts?.realm);
6676
}
6777

68-
if (opts.username && username !== opts.username) {
69-
throw autheFailed(event, opts?.realm);
70-
}
71-
if (opts.password && password !== opts.password) {
72-
throw autheFailed(event, opts?.realm);
73-
}
74-
if (opts.validate && !(await opts.validate(username, password))) {
75-
throw autheFailed(event, opts?.realm);
78+
if (
79+
(opts.username && !timingSafeEqual(username, opts.username)) ||
80+
(opts.password && !timingSafeEqual(password, opts.password)) ||
81+
(opts.validate && !(await opts.validate(username, password)))
82+
) {
83+
await randomJitter();
84+
throw authFailed(event, opts?.realm);
7685
}
7786

7887
const context = getEventContext<H3EventContext>(event);
@@ -97,7 +106,7 @@ export function basicAuth(opts: BasicAuthOptions): Middleware {
97106
};
98107
}
99108

100-
function autheFailed(event: HTTPEvent, realm: string = "") {
109+
function authFailed(event: HTTPEvent, realm: string = "") {
101110
return new HTTPError({
102111
status: 401,
103112
statusText: "Authentication required",

src/utils/internal/auth.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const _textEncoder = new TextEncoder();
2+
3+
/**
4+
* Constant-time string comparison to prevent timing attacks.
5+
* Uses UTF-8 byte comparison for proper Unicode handling.
6+
* Always compares all bytes regardless of where differences occur.
7+
*/
8+
export function timingSafeEqual(a: string, b: string): boolean {
9+
const aBuf = _textEncoder.encode(a);
10+
const bBuf = _textEncoder.encode(b);
11+
const aLen = aBuf.length;
12+
const bLen = bBuf.length;
13+
// Always compare against the longer buffer length to avoid length-based timing leaks
14+
const len = Math.max(aLen, bLen);
15+
let result = aLen === bLen ? 0 : 1;
16+
for (let i = 0; i < len; i++) {
17+
// Use bitwise XOR to compare bytes; accumulate differences with OR
18+
result |= (aBuf[i % aLen] ?? 0) ^ (bBuf[i % bLen] ?? 0);
19+
}
20+
return result === 0;
21+
}
22+
23+
/**
24+
* Add random delay (0-100ms) to prevent timing-based credential inference.
25+
*/
26+
export function randomJitter(): Promise<void> {
27+
const jitter = Math.floor(Math.random() * 100);
28+
return new Promise((resolve) => setTimeout(resolve, jitter));
29+
}

test/auth.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,218 @@ describeMatrix("auth", (t, { it, expect }) => {
3838
expect(await result.text()).toBe("Hello, world!");
3939
expect(result.status).toBe(200);
4040
});
41+
42+
it("handles password containing colons", async () => {
43+
const authWithColon = basicAuth({
44+
username: "admin",
45+
password: "pass:word:with:colons",
46+
});
47+
t.app.get("/colon-test", () => "Success!", { middleware: [authWithColon] });
48+
49+
const result = await t.fetch("/colon-test", {
50+
method: "GET",
51+
headers: {
52+
Authorization: `Basic ${Buffer.from("admin:pass:word:with:colons").toString("base64")}`,
53+
},
54+
});
55+
56+
expect(await result.text()).toBe("Success!");
57+
expect(result.status).toBe(200);
58+
});
59+
60+
it("rejects wrong password when password contains colons", async () => {
61+
const authWithColon = basicAuth({
62+
username: "admin",
63+
password: "pass:word:with:colons",
64+
});
65+
t.app.get("/colon-reject", () => "Success!", {
66+
middleware: [authWithColon],
67+
});
68+
69+
const result = await t.fetch("/colon-reject", {
70+
method: "GET",
71+
headers: {
72+
Authorization: `Basic ${Buffer.from("admin:pass:word").toString("base64")}`,
73+
},
74+
});
75+
76+
expect(result.status).toBe(401);
77+
});
78+
79+
it("responds 401 for invalid base64", async () => {
80+
t.app.get("/invalid-base64", () => "Hello, world!", { middleware: [auth] });
81+
const result = await t.fetch("/invalid-base64", {
82+
method: "GET",
83+
headers: {
84+
Authorization: "Basic !!!invalid-base64!!!",
85+
},
86+
});
87+
88+
expect(result.status).toBe(401);
89+
});
90+
91+
it("responds 401 when base64 value has no colon separator", async () => {
92+
t.app.get("/no-colon", () => "Hello, world!", { middleware: [auth] });
93+
const result = await t.fetch("/no-colon", {
94+
method: "GET",
95+
headers: {
96+
Authorization: `Basic ${Buffer.from("usernameonly").toString("base64")}`,
97+
},
98+
});
99+
100+
expect(result.status).toBe(401);
101+
});
102+
103+
it("responds 401 when username is empty", async () => {
104+
t.app.get("/empty-username", () => "Hello, world!", { middleware: [auth] });
105+
const result = await t.fetch("/empty-username", {
106+
method: "GET",
107+
headers: {
108+
Authorization: `Basic ${Buffer.from(":password").toString("base64")}`,
109+
},
110+
});
111+
112+
expect(result.status).toBe(401);
113+
});
114+
115+
it("responds 401 when password is empty", async () => {
116+
t.app.get("/empty-password", () => "Hello, world!", { middleware: [auth] });
117+
const result = await t.fetch("/empty-password", {
118+
method: "GET",
119+
headers: {
120+
Authorization: `Basic ${Buffer.from("username:").toString("base64")}`,
121+
},
122+
});
123+
124+
expect(result.status).toBe(401);
125+
});
126+
127+
it("responds 401 when both username and password are empty", async () => {
128+
t.app.get("/empty-both", () => "Hello, world!", { middleware: [auth] });
129+
const result = await t.fetch("/empty-both", {
130+
method: "GET",
131+
headers: {
132+
Authorization: `Basic ${Buffer.from(":").toString("base64")}`,
133+
},
134+
});
135+
136+
expect(result.status).toBe(401);
137+
});
138+
139+
it("responds 401 when auth type is not Basic", async () => {
140+
t.app.get("/wrong-type", () => "Hello, world!", { middleware: [auth] });
141+
const result = await t.fetch("/wrong-type", {
142+
method: "GET",
143+
headers: {
144+
Authorization: "Bearer some-token",
145+
},
146+
});
147+
148+
expect(result.status).toBe(401);
149+
});
150+
151+
it("responds 401 when auth header has no credentials part", async () => {
152+
t.app.get("/no-credentials", () => "Hello, world!", {
153+
middleware: [auth],
154+
});
155+
const result = await t.fetch("/no-credentials", {
156+
method: "GET",
157+
headers: {
158+
Authorization: "Basic",
159+
},
160+
});
161+
162+
expect(result.status).toBe(401);
163+
});
164+
165+
it("responds 401 when auth header is empty", async () => {
166+
t.app.get("/empty-header", () => "Hello, world!", { middleware: [auth] });
167+
const result = await t.fetch("/empty-header", {
168+
method: "GET",
169+
headers: {
170+
Authorization: "",
171+
},
172+
});
173+
174+
expect(result.status).toBe(401);
175+
});
176+
177+
it("supports custom validate function", async () => {
178+
const customAuth = basicAuth({
179+
validate: (username, password) => {
180+
return username === "custom" && password === "secret";
181+
},
182+
});
183+
t.app.get("/custom-validate", () => "Custom validated!", {
184+
middleware: [customAuth],
185+
});
186+
187+
const result = await t.fetch("/custom-validate", {
188+
method: "GET",
189+
headers: {
190+
Authorization: `Basic ${Buffer.from("custom:secret").toString("base64")}`,
191+
},
192+
});
193+
194+
expect(await result.text()).toBe("Custom validated!");
195+
expect(result.status).toBe(200);
196+
});
197+
198+
it("rejects invalid credentials with custom validate function", async () => {
199+
const customAuth = basicAuth({
200+
validate: (username, password) => {
201+
return username === "custom" && password === "secret";
202+
},
203+
});
204+
t.app.get("/custom-validate-reject", () => "Custom validated!", {
205+
middleware: [customAuth],
206+
});
207+
208+
const result = await t.fetch("/custom-validate-reject", {
209+
method: "GET",
210+
headers: {
211+
Authorization: `Basic ${Buffer.from("custom:wrong").toString("base64")}`,
212+
},
213+
});
214+
215+
expect(result.status).toBe(401);
216+
});
217+
218+
it("supports async custom validate function", async () => {
219+
const asyncAuth = basicAuth({
220+
validate: async (username, password) => {
221+
await new Promise((resolve) => setTimeout(resolve, 10));
222+
return username === "async" && password === "pass";
223+
},
224+
});
225+
t.app.get("/async-validate", () => "Async validated!", {
226+
middleware: [asyncAuth],
227+
});
228+
229+
const result = await t.fetch("/async-validate", {
230+
method: "GET",
231+
headers: {
232+
Authorization: `Basic ${Buffer.from("async:pass").toString("base64")}`,
233+
},
234+
});
235+
236+
expect(await result.text()).toBe("Async validated!");
237+
expect(result.status).toBe(200);
238+
});
239+
240+
it("throws error when neither password nor validate is provided", async () => {
241+
const invalidAuth = basicAuth({} as any);
242+
t.app.get("/no-auth-config", () => "Should not reach!", {
243+
middleware: [invalidAuth],
244+
});
245+
246+
const result = await t.fetch("/no-auth-config", {
247+
method: "GET",
248+
headers: {
249+
Authorization: `Basic ${Buffer.from("user:pass").toString("base64")}`,
250+
},
251+
});
252+
253+
expect(result.status).toBe(500);
254+
});
41255
});

0 commit comments

Comments
 (0)