Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/quill/src/lib/polar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,14 @@ export async function verifyWebhook(
const candidates = sigHeader.split(" ");
for (const c of candidates) {
const [version, sig] = c.split(",");
if (version === "v1" && sig && (await timingSafeEqual(sig, expected))) {
if (
version === "v1" &&
sig &&
(await timingSafeEqual(
new TextEncoder().encode(sig),
new TextEncoder().encode(expected)
))
) {
Comment on lines +187 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

While timingSafeEqual prevents timing attacks on the content of the secrets, comparing strings of different lengths directly still leaks the length of the secret because timingSafeEqual returns early when the byte lengths of the inputs do not match.

To completely eliminate length-based timing leaks, hash both values using SHA-256 before passing them to timingSafeEqual. This ensures both inputs always have a fixed length of 32 bytes.

Suggested change
if (
version === "v1" &&
sig &&
(await timingSafeEqual(
new TextEncoder().encode(sig),
new TextEncoder().encode(expected)
))
) {
if (
version === "v1" &&
sig &&
(await timingSafeEqual(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sig)),
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(expected))
))
) {

return true;
}
}
Expand Down
8 changes: 7 additions & 1 deletion apps/quill/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ adminRouter.use("*", async (c, next) => {
throw new HTTPException(503, { message: "Admin API not configured." });
}
const provided = c.req.header("x-admin-key");
if (!provided || !(await timingSafeEqual(expected, provided))) {
if (
!provided ||
!(await timingSafeEqual(
new TextEncoder().encode(expected),
new TextEncoder().encode(provided)
))
) {
Comment on lines +22 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Directly comparing secrets of different lengths using timingSafeEqual leaks the length of the expected admin key because the comparison returns early if the byte lengths do not match.

To prevent this length-based timing side-channel, hash both the expected and provided keys with SHA-256 before comparing them. This guarantees that both inputs to timingSafeEqual are always exactly 32 bytes.

Suggested change
if (
!provided ||
!(await timingSafeEqual(
new TextEncoder().encode(expected),
new TextEncoder().encode(provided)
))
) {
if (
!provided ||
!(await timingSafeEqual(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(expected)),
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(provided))
))
) {

throw new HTTPException(401, { message: "Invalid admin key." });
}
await next();
Expand Down
Loading