Jiseoup/showmycodePublic
EN|KO
  • Code
  • Commits
  • Pull Requests
← Back to list

fix: use timing-safe comparison and hash cookie value

JiseoupJiseoup · Jun 13, 202602147d5

Files changed3+39 -9

Changed files

+39 -9 · 3

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-
-const COOKIE_NAME = "smc_auth";
+import { COOKIE_NAME, verifyToken, cookieValue } from "@/lib/auth";
// Validates a share token submitted from the unauthorized page.
// On success, sets the auth cookie; on failure, returns 401 without leaking details.
@@ -19,12 +18,12 @@ export async function POST(request: NextRequest) {
// Invalid JSON body — treat as failed auth.
}
- if (!submitted || submitted !== expected) {
+ if (!submitted || !verifyToken(submitted, expected)) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
- response.cookies.set(COOKIE_NAME, expected, {
+ response.cookies.set(COOKIE_NAME, cookieValue(expected), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
@@ -0,0 +1,32 @@
+import { timingSafeEqual, createHmac } from "crypto";
+
+const COOKIE_NAME = "smc_auth";
+
+// HMAC key derived from the SHARE_TOKEN itself. This is acceptable because the
+// goal is not to protect the token from the server (which already knows it) but
+// to avoid storing the raw token in the client cookie.
+function hmacSign(value: string): string {
+ return createHmac("sha256", value).update("smc_cookie").digest("hex");
+}
+
+function safeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}
+
+/** Constant-time comparison of a submitted token against the expected SHARE_TOKEN. */
+export function verifyToken(submitted: string, expected: string): boolean {
+ return safeEqual(submitted, expected);
+}
+
+/** Value to store in the auth cookie (HMAC of the token, not the raw token). */
+export function cookieValue(token: string): string {
+ return hmacSign(token);
+}
+
+/** Check whether a cookie value is valid for the given SHARE_TOKEN. */
+export function verifyCookie(value: string, token: string): boolean {
+ return safeEqual(value, hmacSign(token));
+}
+
+export { COOKIE_NAME };
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { locales, defaultLocale, hasLocale } from "@/lib/i18n";
-
-const COOKIE_NAME = "smc_auth";
+import { COOKIE_NAME, verifyToken, verifyCookie, cookieValue } from "@/lib/auth";
// Paths that are accessible without a valid auth cookie.
const PUBLIC_PATHS = ["/unauthorized"];
@@ -47,11 +46,11 @@ export function proxy(request: NextRequest) {
// This allows sharing a plain URL like https://example.com/?token=xxx.
const queryToken = request.nextUrl.searchParams.get("token");
if (queryToken !== null) {
- if (queryToken === token) {
+ if (verifyToken(queryToken, token)) {
const url = new URL(request.url);
url.searchParams.delete("token");
const response = NextResponse.redirect(url);
- response.cookies.set(COOKIE_NAME, token, {
+ response.cookies.set(COOKIE_NAME, cookieValue(token), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
@@ -66,7 +65,7 @@ export function proxy(request: NextRequest) {
// Check auth cookie for subsequent requests.
const cookie = request.cookies.get(COOKIE_NAME);
- if (cookie?.value === token) {
+ if (cookie?.value && verifyCookie(cookie.value, token)) {
return redirectToLocale(request) ?? NextResponse.next();
}