This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+110
-93
@@ -1,7 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import crypto from "node:crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import type { IpAddress } from "./types";
|
||||
|
||||
@@ -10,134 +9,152 @@ const CSRF_COOKIE = "__Host-csrf-token";
|
||||
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
||||
|
||||
const ALLOWED_HOSTS: ReadonlySet<string> = new Set(
|
||||
[env.APP_URL ? new URL(env.APP_URL).host : "", "localhost", "127.0.0.1"].filter(Boolean),
|
||||
[
|
||||
env.APP_URL ? new URL(env.APP_URL).host : "",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
].filter(Boolean),
|
||||
);
|
||||
|
||||
const SAFE_REDIRECT_PATHS = new Set([
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
]);
|
||||
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function safeRedirect(destination: string, fallback: string = "/"): string {
|
||||
try {
|
||||
const url = new URL(destination, env.APP_URL || "http://localhost:3000");
|
||||
if (ALLOWED_HOSTS.has(url.host)) return destination;
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1") return destination;
|
||||
} catch {
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
export function safeRedirect(
|
||||
destination: string,
|
||||
fallback: string = "/",
|
||||
): string {
|
||||
try {
|
||||
const url = new URL(destination, env.APP_URL || "http://localhost:3000");
|
||||
if (ALLOWED_HOSTS.has(url.host)) return destination;
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1")
|
||||
return destination;
|
||||
} catch {
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function redirectSafe(destination: string, fallback: string = "/"): never {
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
export function redirectSafe(
|
||||
destination: string,
|
||||
fallback: string = "/",
|
||||
): never {
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
}
|
||||
|
||||
function csrfCookieOpts(): {
|
||||
name: string;
|
||||
value: string;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
maxAge: number;
|
||||
name: string;
|
||||
value: string;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
maxAge: number;
|
||||
} {
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
||||
};
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCsrfCookie(): Promise<string> {
|
||||
const c = await cookies();
|
||||
const existing = c.get(CSRF_COOKIE);
|
||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value;
|
||||
const opts = csrfCookieOpts();
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return opts.value;
|
||||
const c = await cookies();
|
||||
const existing = c.get(CSRF_COOKIE);
|
||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2)
|
||||
return existing.value;
|
||||
const opts = csrfCookieOpts();
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return opts.value;
|
||||
}
|
||||
|
||||
export async function validateCsrfToken(token: string): Promise<boolean> {
|
||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||
try {
|
||||
const c = await cookies();
|
||||
const stored = c.get(CSRF_COOKIE)?.value;
|
||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||
try {
|
||||
const c = await cookies();
|
||||
const stored = c.get(CSRF_COOKIE)?.value;
|
||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalize(input: string): string {
|
||||
return input.normalize("NFC").trim();
|
||||
return input.normalize("NFC").trim();
|
||||
}
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/;
|
||||
|
||||
function removeControlChars(s: string): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 32) result += s.charAt(i);
|
||||
}
|
||||
return result;
|
||||
let result = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 32) result += s.charAt(i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return removeControlChars(
|
||||
name
|
||||
.normalize("NFC")
|
||||
.replace(INVALID_FILENAME_CHARS, "")
|
||||
.replace(/\.\.(?:\/|$)/g, ""),
|
||||
)
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return removeControlChars(
|
||||
name
|
||||
.normalize("NFC")
|
||||
.replace(INVALID_FILENAME_CHARS, "")
|
||||
.replace(/\.\.(?:\/|$)/g, ""),
|
||||
)
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?: number): string {
|
||||
const s = canonicalize(String(value ?? ""));
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
export function canonicalizeFormValue(
|
||||
value: FormDataEntryValue | null,
|
||||
maxLen?: number,
|
||||
): string {
|
||||
const s = canonicalize(String(value ?? ""));
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
export function canonicalizeFormData(
|
||||
formData: FormData,
|
||||
fields: Record<string, number | undefined>,
|
||||
formData: FormData,
|
||||
fields: Record<string, number | undefined>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [
|
||||
key,
|
||||
canonicalizeFormValue(formData.get(key), maxLen),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
try {
|
||||
const h = await headers();
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user