feat(auth): enhance custom-domain same-origin handling and proxy trust validation
This commit is contained in:
1 parent
eb80814d6a
commit
25b10f04da
3 files changed
+140
-7
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user