feat(auth): enhance custom-domain same-origin handling and proxy trust validation

This commit is contained in:
KodekaTeam committed 2026-09-12 09:25:27 +07:00
1 parent eb80814d6a
commit 25b10f04da
3 files changed
+140 -7

No files matched your search

+5 -1
View File
@@ -15,7 +15,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:4000
# /* --------------- Connection settings for PostgreSQL database -------------- */
# Frontend server
# Frontend server. Optional for same-origin browser access; recommended as the
# canonical callback/fallback origin in production. Use https and no path.
FRONTEND_URL=http://localhost:3000
# Optional canonical/backend URL if ever needed
@@ -35,6 +36,9 @@ SESSION_ABSOLUTE_TIMEOUT_HOURS=168
# /* -------------------------- Reverse proxy / rate limiting ------------------------- */
# Set true only when Doktainer is behind a trusted reverse proxy that overwrites
# X-Forwarded-Host and X-Forwarded-Proto. This enables the effective-origin
# fallback for clients that do not send Fetch Metadata.
TRUST_PROXY=false
RATE_LIMIT_MAX=300
RATE_LIMIT_TIME_WINDOW=1 minute
+59 -6
View File
@@ -36,16 +36,61 @@ function getHeaderValue(value: string | string[] | undefined): string | null {
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const BROWSER_MUTATION_HEADER = "x-doktainer-request";
function getAllowedBrowserOrigins(env = process.env): Set<string> {
const origins = [env.FRONTEND_URL, ...(env.CORS_ORIGINS ?? "").split(",")]
.map((origin) => origin?.trim().replace(/\/$/, ""))
function normalizeHttpOrigin(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.origin;
} catch {
return null;
}
}
function firstForwardedValue(value: string | null): string | null {
return value?.split(",")[0]?.trim() || null;
}
function getEffectiveRequestOrigin(
req: FastifyRequest,
env: NodeJS.ProcessEnv,
): string | null {
const trustProxy = env.TRUST_PROXY === "true";
const forwardedHost = trustProxy
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-host"]))
: null;
const forwardedProtocol = trustProxy
? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-proto"]))
: null;
const host = forwardedHost || getHeaderValue(req.headers.host);
const protocol = forwardedProtocol || req.protocol;
if (!host || !protocol) return null;
return normalizeHttpOrigin(`${protocol.replace(/:$/, "")}://${host}`);
}
function getAllowedBrowserOrigins(
req: FastifyRequest,
env: NodeJS.ProcessEnv,
): Set<string> {
const origins = [
env.NEXT_PUBLIC_PANEL_URL,
env.FRONTEND_URL,
...(env.CORS_ORIGINS ?? "").split(","),
]
.map(normalizeHttpOrigin)
.filter((origin): origin is string => Boolean(origin));
const requestOrigin = getEffectiveRequestOrigin(req, env);
if (requestOrigin) origins.push(requestOrigin);
return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]);
}
export function enforceBrowserMutationProtection(
req: FastifyRequest,
reply: FastifyReply,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
@@ -57,7 +102,8 @@ export function enforceBrowserMutationProtection(
return false;
}
if (getHeaderValue(req.headers["sec-fetch-site"]) === "cross-site") {
const fetchSite = getHeaderValue(req.headers["sec-fetch-site"]);
if (fetchSite === "cross-site") {
reply.status(403).send({
success: false,
error: "Forbidden — cross-site request rejected",
@@ -65,8 +111,15 @@ export function enforceBrowserMutationProtection(
return false;
}
const origin = getHeaderValue(req.headers.origin)?.replace(/\/$/, "");
if (origin && !getAllowedBrowserOrigins().has(origin)) {
const rawOrigin = getHeaderValue(req.headers.origin);
const origin = normalizeHttpOrigin(rawOrigin);
const browserConfirmedSameOrigin = fetchSite === "same-origin";
if (
rawOrigin &&
(!origin ||
(!browserConfirmedSameOrigin &&
!getAllowedBrowserOrigins(req, env).has(origin)))
) {
reply.status(403).send({
success: false,
error: "Forbidden — request origin is not allowed",
@@ -106,6 +106,82 @@ test("safe methods and verified same-origin mutations are accepted", () => {
);
});
test("browser-confirmed same-origin mutations use the active origin without env configuration", () => {
const allowed = enforceBrowserMutationProtection(
{
method: "POST",
protocol: "http",
headers: {
host: "127.0.0.1:4000",
origin: "https://panel.example.com",
"sec-fetch-site": "same-origin",
"x-doktainer-request": "1",
},
} as never,
createReplyRecorder() as never,
{} as NodeJS.ProcessEnv,
);
assert.equal(allowed, true);
});
test("verified mutations accept the effective custom-domain request origin", () => {
const allowed = enforceBrowserMutationProtection(
{
method: "POST",
protocol: "https",
headers: {
host: "panel.example.com",
origin: "https://panel.example.com",
"sec-fetch-site": "same-site",
"x-doktainer-request": "1",
},
} as never,
createReplyRecorder() as never,
{
FRONTEND_URL: "http://localhost:3000",
TRUST_PROXY: "false",
} as NodeJS.ProcessEnv,
);
assert.equal(allowed, true);
});
test("forwarded custom-domain origin is trusted only behind an enabled proxy", () => {
const request = {
method: "POST",
protocol: "http",
headers: {
host: "127.0.0.1:4000",
origin: "https://panel.example.com",
"sec-fetch-site": "same-site",
"x-doktainer-request": "1",
"x-forwarded-host": "panel.example.com",
"x-forwarded-proto": "https",
},
} as never;
assert.equal(
enforceBrowserMutationProtection(
request,
createReplyRecorder() as never,
{ TRUST_PROXY: "true" } as NodeJS.ProcessEnv,
),
true,
);
const untrustedReply = createReplyRecorder();
assert.equal(
enforceBrowserMutationProtection(
request,
untrustedReply as never,
{ TRUST_PROXY: "false" } as NodeJS.ProcessEnv,
),
false,
);
assert.equal(untrustedReply.statusCode, 403);
});
test("only owners and super admins can revoke a managed login session", () => {
assert.equal(
canRevokeManagedSession({