mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 21:45:04 +10:00
feat(security): harden auth, oauth, and printer endpoints
Add stricter URL and redirect validation, endpoint rate limiting, safer defaults for printer and compose config, and CSP protections across server and API surfaces. Made-with: Cursor
This commit is contained in:
@@ -44,12 +44,16 @@ export const env = createEnv({
|
||||
OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(),
|
||||
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
|
||||
OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(),
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: z.string().optional(),
|
||||
OAUTH_SCOPES: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) => value.split(" "))
|
||||
.default(["openid", "profile", "email"]),
|
||||
|
||||
// AI provider URL restrictions
|
||||
AI_ALLOWED_BASE_URLS: z.string().optional(),
|
||||
|
||||
// Email (SMTP)
|
||||
SMTP_HOST: z.string().min(1).optional(),
|
||||
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
|
||||
export function getReadableErrorMessage(error: unknown, fallback: string): string {
|
||||
if (typeof error === "string" && error) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type ErrorMessageByCode = Record<string, string>;
|
||||
|
||||
export function getOrpcErrorMessage(
|
||||
error: unknown,
|
||||
options: {
|
||||
fallback: string;
|
||||
byCode?: ErrorMessageByCode;
|
||||
allowServerMessage?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (!(error instanceof ORPCError)) return getReadableErrorMessage(error, options.fallback);
|
||||
|
||||
const mappedMessage = options.byCode?.[error.code];
|
||||
if (mappedMessage) return mappedMessage;
|
||||
|
||||
if (options.allowServerMessage && error.message) return error.message;
|
||||
return options.fallback;
|
||||
}
|
||||
|
||||
export function getResumeErrorMessage(error: unknown): string {
|
||||
return getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
RESUME_SLUG_ALREADY_EXISTS: "A resume with this slug already exists.",
|
||||
RESUME_LOCKED: "This resume is locked. Unlock it first to make changes.",
|
||||
},
|
||||
fallback: "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripIpv6Brackets(hostname: string): string {
|
||||
return hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
}
|
||||
|
||||
function isLoopbackOrLocalHostname(hostname: string) {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return (
|
||||
normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost")
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (Number.isNaN(first) || Number.isNaN(second)) return false;
|
||||
|
||||
if (first === 10) return true;
|
||||
if (first === 127) return true;
|
||||
if (first === 169 && second === 254) return true;
|
||||
if (first === 172 && second >= 16 && second <= 31) return true;
|
||||
if (first === 192 && second === 168) return true;
|
||||
if (first === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIPv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
return (
|
||||
normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:")
|
||||
);
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackHost(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
if (isLoopbackOrLocalHostname(normalized)) return true;
|
||||
|
||||
const ipVersion = isIP(normalized);
|
||||
if (ipVersion === 4) return isPrivateIPv4(normalized);
|
||||
if (ipVersion === 6) return isPrivateIPv6(normalized);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseUrl(input: string) {
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAllowedHostList(value?: string) {
|
||||
if (!value) return new Set<string>();
|
||||
|
||||
const hosts = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(hosts);
|
||||
}
|
||||
|
||||
export function isAllowedExternalUrl(input: string, allowedHosts: Set<string>) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) return false;
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
if (isPrivateOrLoopbackHost(parsed.hostname)) return false;
|
||||
|
||||
const hostname = normalizeHostname(parsed.hostname);
|
||||
if (allowedHosts.has(hostname)) return true;
|
||||
|
||||
const origin = parsed.origin.toLowerCase();
|
||||
return allowedHosts.has(origin);
|
||||
}
|
||||
|
||||
export function sanitizeResumePictureUrl(url: string, appUrl: string) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("/uploads/")) return url;
|
||||
|
||||
const parsed = parseUrl(url);
|
||||
if (!parsed) return "";
|
||||
if (parsed.username || parsed.password) return "";
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
||||
|
||||
const app = parseUrl(appUrl);
|
||||
if (!app) return "";
|
||||
|
||||
if (parsed.origin !== app.origin) return "";
|
||||
if (!parsed.pathname.startsWith("/uploads/")) return "";
|
||||
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
Reference in New Issue
Block a user