153 lines
4.3 KiB
TypeScript
153 lines
4.3 KiB
TypeScript
import type { Context } from "jsr:@hono/hono@4";
|
|
|
|
/**
|
|
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
|
|
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
|
*/
|
|
export function getCookieDomain(customRpId?: string): string | undefined {
|
|
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
|
if (envDomain) {
|
|
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
|
}
|
|
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
|
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
|
return undefined;
|
|
}
|
|
const parts = targetId.split(".").filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return `.${parts.slice(-2).join(".")}`;
|
|
}
|
|
return `.${targetId}`;
|
|
}
|
|
|
|
/**
|
|
* Deterministic fast path prefix matcher for dynamic bypasses
|
|
*/
|
|
export function isPathBypassed(
|
|
requestPath: string,
|
|
bypassPaths?: string[],
|
|
): boolean {
|
|
if (!bypassPaths || bypassPaths.length === 0) return false;
|
|
|
|
for (const pattern of bypassPaths) {
|
|
if (pattern === requestPath) return true;
|
|
if (pattern.endsWith("/*")) {
|
|
const prefix = pattern.slice(0, -2); // remove /*
|
|
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Pure native Deno bitwise CIDR matcher
|
|
*/
|
|
export function isIpAllowed(
|
|
clientIp: string,
|
|
allowedCidrs?: string[],
|
|
): boolean {
|
|
if (!allowedCidrs || allowedCidrs.length === 0) return false;
|
|
|
|
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
|
|
const parseIp4 = (ip: string) => {
|
|
const parts = ip.split(".");
|
|
if (parts.length !== 4) return null;
|
|
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
|
|
0;
|
|
};
|
|
|
|
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
|
|
// We extract the first IP (the original client)
|
|
const primaryIp = clientIp.split(",")[0].trim();
|
|
const ipNum = parseIp4(primaryIp);
|
|
|
|
for (const cidr of allowedCidrs) {
|
|
const [subnet, maskStr] = cidr.split("/");
|
|
if (!maskStr) {
|
|
if (subnet === primaryIp) return true;
|
|
continue;
|
|
}
|
|
|
|
// IPv4 CIDR matching
|
|
if (ipNum !== null && subnet.includes(".")) {
|
|
const subnetNum = parseIp4(subnet);
|
|
if (subnetNum !== null) {
|
|
const maskBits = parseInt(maskStr, 10);
|
|
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
|
|
const mask = maskBits === 0
|
|
? 0
|
|
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
|
if ((ipNum & mask) === (subnetNum & mask)) {
|
|
return true;
|
|
}
|
|
}
|
|
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
|
|
// Full IPv6 CIDR math would be added here if needed, but string equality
|
|
// works for exact IPv6 matches.
|
|
else if (subnet === clientIp) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validates a given URL to ensure it is safe to redirect to.
|
|
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
|
|
*/
|
|
export function isSafeRedirectUrl(
|
|
rawUrl: string,
|
|
customDomain?: string,
|
|
): boolean {
|
|
if (!rawUrl) return false;
|
|
// 1. Relative paths within the same origin are safe
|
|
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
|
|
return true;
|
|
}
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
const host = parsed.hostname;
|
|
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
|
|
const cleanRoot = root.replace(/^\./, "");
|
|
|
|
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
|
|
if (
|
|
host === "localhost" ||
|
|
host === "atyg.org" ||
|
|
host.endsWith(".atyg.org") ||
|
|
host === cleanRoot ||
|
|
host.endsWith(`.${cleanRoot}`)
|
|
) {
|
|
return true;
|
|
}
|
|
} catch (_e) {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
|
|
*/
|
|
export function getClientIp(c: Context): string {
|
|
const realIp = c.req.header("x-real-ip");
|
|
if (realIp) {
|
|
return realIp.trim();
|
|
}
|
|
|
|
let forwardedFor = c.req.header("x-forwarded-for");
|
|
if (forwardedFor) {
|
|
if (forwardedFor.length > 256) {
|
|
forwardedFor = forwardedFor.substring(0, 256);
|
|
}
|
|
const parts = forwardedFor.split(",");
|
|
return parts[parts.length - 1].trim();
|
|
}
|
|
|
|
return "127.0.0.1";
|
|
}
|