mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-19 17:19:58 +02:00
✨ handle redirect request in badger's verifySession
This commit is contained in:
+2
-1
@@ -3540,6 +3540,7 @@
|
|||||||
"validEmail": "Valid email",
|
"validEmail": "Valid email",
|
||||||
"validSSO": "Valid SSO",
|
"validSSO": "Valid SSO",
|
||||||
"validVirtualAPIKey": "Valid Virtual API Key",
|
"validVirtualAPIKey": "Valid Virtual API Key",
|
||||||
|
"allowedRedirect": "Allowed Redirect",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"configManaged": "Config Managed",
|
"configManaged": "Config Managed",
|
||||||
"connectedClient": "Connected Client",
|
"connectedClient": "Connected Client",
|
||||||
@@ -4394,7 +4395,7 @@
|
|||||||
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
||||||
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
|
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
|
||||||
"redirectPriorityInvalid": "Enter a whole number between 1 and 1000",
|
"redirectPriorityInvalid": "Enter a whole number between 1 and 1000",
|
||||||
"redirectPriorityDescription": "Higher priority routes are evaluated first. This competes with the priorities of resource targets on the same domain: a redirect only wins over a resource route when its priority is higher. 100 means automatic ordering (system decides).",
|
"redirectPriorityDescription": "Higher priority routes are evaluated first. Redirects are always evaluated before targets.",
|
||||||
"redirectCreate": "Create Redirect",
|
"redirectCreate": "Create Redirect",
|
||||||
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
||||||
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import {
|
|||||||
resourcePolicyHeaderAuth,
|
resourcePolicyHeaderAuth,
|
||||||
ResourcePolicyHeaderAuth,
|
ResourcePolicyHeaderAuth,
|
||||||
resourceWhitelist,
|
resourceWhitelist,
|
||||||
resourcePolicyWhiteList
|
resourcePolicyWhiteList,
|
||||||
|
redirects,
|
||||||
|
domains
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { alias } from "@server/db";
|
import { alias } from "@server/db";
|
||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export type ResourceWithAuth = {
|
export type ResourceWithAuth = {
|
||||||
@@ -53,11 +55,74 @@ export type ResourceWithAuth = {
|
|||||||
org: Org;
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RedirectByHost = {
|
||||||
|
redirectId: number;
|
||||||
|
orgId: string;
|
||||||
|
matchPath: string | null;
|
||||||
|
pathMatchType: string;
|
||||||
|
destinationDomain: string;
|
||||||
|
rewritePath: string | null;
|
||||||
|
rewritePathType: string | null;
|
||||||
|
permanent: boolean;
|
||||||
|
priority: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserSessionWithUser = {
|
export type UserSessionWithUser = {
|
||||||
session: any;
|
session: any;
|
||||||
user: any;
|
user: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled redirects listening on the given host, highest priority first.
|
||||||
|
* A redirect listens on its resource's fullDomain when attached to one,
|
||||||
|
* otherwise on subdomain.baseDomain (or the bare baseDomain) of its domain.
|
||||||
|
* Mirrors the host resolution in getTraefikConfig so badger agrees with
|
||||||
|
* what Traefik routed.
|
||||||
|
*/
|
||||||
|
export async function getRedirectsByHost(
|
||||||
|
host: string
|
||||||
|
): Promise<RedirectByHost[]> {
|
||||||
|
// A literal "*." leading label matches any single subdomain, like
|
||||||
|
// wildcard resources do.
|
||||||
|
const parts = host.split(".");
|
||||||
|
const candidates = [host];
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
candidates.push(`*.${parts.slice(i).join(".")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectHost = sql<string>`case
|
||||||
|
when ${redirects.resourceId} is not null then ${resources.fullDomain}
|
||||||
|
when ${redirects.subdomain} is null then ${domains.baseDomain}
|
||||||
|
else ${redirects.subdomain} || '.' || ${domains.baseDomain}
|
||||||
|
end`;
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
redirectId: redirects.redirectId,
|
||||||
|
orgId: redirects.orgId,
|
||||||
|
matchPath: redirects.matchPath,
|
||||||
|
pathMatchType: redirects.pathMatchType,
|
||||||
|
destinationDomain: redirects.destinationDomain,
|
||||||
|
rewritePath: redirects.rewritePath,
|
||||||
|
rewritePathType: redirects.rewritePathType,
|
||||||
|
permanent: redirects.permanent,
|
||||||
|
priority: redirects.priority
|
||||||
|
})
|
||||||
|
.from(redirects)
|
||||||
|
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||||
|
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(redirects.enabled, true),
|
||||||
|
// Traefik drops resource-attached redirects along with a
|
||||||
|
// disabled resource; do the same here.
|
||||||
|
or(isNull(redirects.resourceId), eq(resources.enabled, true)),
|
||||||
|
inArray(redirectHost, candidates)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(redirects.priority));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resource by domain with pincode and password information
|
* Get resource by domain with pincode and password information
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { assertEquals } from "../../../test/assert";
|
||||||
|
import { rewriteRequestPath } from "./middleware";
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log("Running rewriteRequestPath tests...");
|
||||||
|
|
||||||
|
// no rewrite configured
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/a/b", "/a", "prefix", null, null),
|
||||||
|
"/a/b",
|
||||||
|
"no rewrite type"
|
||||||
|
);
|
||||||
|
|
||||||
|
// exact rewrite
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "exact", "/new", "exact"),
|
||||||
|
"/new",
|
||||||
|
"exact -> exact"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/x", "/old", "exact", "/new", "exact"),
|
||||||
|
"/old/x",
|
||||||
|
"exact rewrite only on exact match"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "exact", "new", "exact"),
|
||||||
|
"/new",
|
||||||
|
"leading slash added to rewrite"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/anything", null, null, "/new", "exact"),
|
||||||
|
"/new",
|
||||||
|
"no match path + exact rewrite replaces path"
|
||||||
|
);
|
||||||
|
|
||||||
|
// prefix rewrite
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/a/b", "/old", "prefix", "/new", "prefix"),
|
||||||
|
"/new/a/b",
|
||||||
|
"prefix -> prefix keeps rest"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "prefix", "/new", "prefix"),
|
||||||
|
"/new",
|
||||||
|
"prefix -> prefix bare"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "exact", "/new", "prefix"),
|
||||||
|
"/new",
|
||||||
|
"exact -> prefix"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath(
|
||||||
|
"/api/v1/users",
|
||||||
|
"^/api/v1/(.*)",
|
||||||
|
"regex",
|
||||||
|
"/v2/$1",
|
||||||
|
"prefix"
|
||||||
|
),
|
||||||
|
"/v2/users",
|
||||||
|
"regex -> prefix uses capture"
|
||||||
|
);
|
||||||
|
|
||||||
|
// regex rewrite
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath(
|
||||||
|
"/blog/2020/post",
|
||||||
|
"^/blog/(\\d+)/(.*)$",
|
||||||
|
"regex",
|
||||||
|
"/archive/$2-$1",
|
||||||
|
"regex"
|
||||||
|
),
|
||||||
|
"/archive/post-2020",
|
||||||
|
"regex -> regex"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/x", "/old", "prefix", "/new$1", "regex"),
|
||||||
|
"/new/x",
|
||||||
|
"prefix -> regex has (.*) capture"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "exact", "/new", "regex"),
|
||||||
|
"/new",
|
||||||
|
"exact -> regex"
|
||||||
|
);
|
||||||
|
|
||||||
|
// stripPrefix
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/a", "/old", "prefix", null, "stripPrefix"),
|
||||||
|
"/a",
|
||||||
|
"stripPrefix"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "prefix", null, "stripPrefix"),
|
||||||
|
"/",
|
||||||
|
"stripPrefix to root"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/a", "/old", "prefix", "/new", "stripPrefix"),
|
||||||
|
"/new/a",
|
||||||
|
"stripPrefix + addPrefix"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old", "/old", "exact", null, "stripPrefix"),
|
||||||
|
"/",
|
||||||
|
"stripPrefix exact"
|
||||||
|
);
|
||||||
|
// Same result the replacePathRegex middleware produces for this config
|
||||||
|
// (regex `^/old` -> `/`), quirky double slash included.
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/a", "^/old", "regex", null, "stripPrefix"),
|
||||||
|
"//a",
|
||||||
|
"stripPrefix regex mirrors Traefik"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/old/a", "^/old/", "regex", null, "stripPrefix"),
|
||||||
|
"/a",
|
||||||
|
"stripPrefix regex with trailing slash"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
rewriteRequestPath("/a/b", null, null, null, "stripPrefix"),
|
||||||
|
"/a/b",
|
||||||
|
"stripPrefix without match path is a no-op"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("All rewriteRequestPath tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -194,6 +194,78 @@ export default function createPathRewriteMiddleware(
|
|||||||
return { middlewares };
|
return { middlewares };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a path rewrite to a request path in-process, producing the same
|
||||||
|
* result the replacePathRegex / stripPrefix middlewares built above would.
|
||||||
|
* Used where Pangolin issues the redirect itself (badger) instead of
|
||||||
|
* handing it to Traefik.
|
||||||
|
*/
|
||||||
|
export function rewriteRequestPath(
|
||||||
|
requestPath: string,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null,
|
||||||
|
rewritePath: string | null,
|
||||||
|
rewritePathType: string | null
|
||||||
|
): string {
|
||||||
|
if (!rewritePathType) {
|
||||||
|
return requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = rewritePath ?? "";
|
||||||
|
if (
|
||||||
|
rewritePathType !== "regex" &&
|
||||||
|
target !== "" &&
|
||||||
|
!target.startsWith("/")
|
||||||
|
) {
|
||||||
|
target = `/${target}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was matched against, so there is nothing to strip or replace;
|
||||||
|
// an exact rewrite is the only one that still means something.
|
||||||
|
if (!path || !pathMatchType) {
|
||||||
|
return rewritePathType === "exact" ? target || "/" : requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched = path;
|
||||||
|
if (pathMatchType !== "regex" && !matched.startsWith("/")) {
|
||||||
|
matched = `/${matched}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchRegex =
|
||||||
|
pathMatchType === "regex"
|
||||||
|
? matched
|
||||||
|
: pathMatchType === "prefix"
|
||||||
|
? `^${escapeRegex(matched)}(.*)`
|
||||||
|
: `^${escapeRegex(matched)}$`;
|
||||||
|
|
||||||
|
switch (rewritePathType) {
|
||||||
|
case "exact":
|
||||||
|
return requestPath.replace(
|
||||||
|
new RegExp(`^${escapeRegex(matched)}$`),
|
||||||
|
target
|
||||||
|
);
|
||||||
|
case "prefix":
|
||||||
|
return requestPath.replace(
|
||||||
|
new RegExp(matchRegex),
|
||||||
|
pathMatchType === "prefix" ? `${target}$1` : target
|
||||||
|
);
|
||||||
|
case "regex":
|
||||||
|
return requestPath.replace(new RegExp(matchRegex), target);
|
||||||
|
case "stripPrefix": {
|
||||||
|
if (pathMatchType === "prefix") {
|
||||||
|
const stripped = requestPath.startsWith(matched)
|
||||||
|
? requestPath.slice(matched.length)
|
||||||
|
: requestPath;
|
||||||
|
const prefix = target && target !== "/" ? target : "";
|
||||||
|
return `${prefix}${stripped}` || "/";
|
||||||
|
}
|
||||||
|
return requestPath.replace(new RegExp(matchRegex), target || "/");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return requestPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegex(string: string): string {
|
function escapeRegex(string: string): string {
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import config from "@server/lib/config";
|
|||||||
import {
|
import {
|
||||||
buildHostRule,
|
buildHostRule,
|
||||||
appendPathMatch,
|
appendPathMatch,
|
||||||
computeRoutePriority
|
computeRedirectPriority
|
||||||
} from "@server/lib/traefik/rule";
|
} from "@server/lib/traefik/rule";
|
||||||
|
|
||||||
export type RedirectRouteRow = {
|
export type RedirectRouteRow = {
|
||||||
@@ -97,17 +97,11 @@ export function buildRedirectConfig(params: {
|
|||||||
redirect.pathMatchType
|
redirect.pathMatchType
|
||||||
);
|
);
|
||||||
|
|
||||||
// A redirect attached to a resource must win over that resource's
|
const priority = computeRedirectPriority(
|
||||||
// router at the same host/path specificity, so nudge derived
|
|
||||||
// priorities up by one. Explicit priorities are used as-is.
|
|
||||||
const hasExplicitPriority =
|
|
||||||
!!redirect.priority && redirect.priority !== 100;
|
|
||||||
const priority =
|
|
||||||
computeRoutePriority(
|
|
||||||
redirect.priority,
|
redirect.priority,
|
||||||
redirect.matchPath,
|
redirect.matchPath,
|
||||||
redirect.pathMatchType
|
redirect.pathMatchType
|
||||||
) + (hasExplicitPriority ? 0 : 1);
|
);
|
||||||
|
|
||||||
// if resource is already attached to resource, we don't need to add the https redirect
|
// if resource is already attached to resource, we don't need to add the https redirect
|
||||||
// as it is already added in the resource traefik config
|
// as it is already added in the resource traefik config
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { assertEquals } from "../../../test/assert";
|
||||||
|
import {
|
||||||
|
computeRedirectPriority,
|
||||||
|
computeRoutePriority,
|
||||||
|
matchesPath
|
||||||
|
} from "./rule";
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log("Running matchesPath tests...");
|
||||||
|
|
||||||
|
// No path config matches everything
|
||||||
|
assertEquals(matchesPath("/anything", null, null), true, "null path");
|
||||||
|
assertEquals(matchesPath("/anything", "/a", null), true, "null type");
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/anything", null, "prefix"),
|
||||||
|
true,
|
||||||
|
"null path w/ type"
|
||||||
|
);
|
||||||
|
|
||||||
|
// exact
|
||||||
|
assertEquals(matchesPath("/api", "/api", "exact"), true, "exact match");
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/api/", "/api", "exact"),
|
||||||
|
false,
|
||||||
|
"exact trailing slash"
|
||||||
|
);
|
||||||
|
assertEquals(matchesPath("/api/x", "/api", "exact"), false, "exact child");
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/api", "api", "exact"),
|
||||||
|
true,
|
||||||
|
"exact leading slash added"
|
||||||
|
);
|
||||||
|
|
||||||
|
// prefix (segment-aware, like Traefik v3 PathPrefix)
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/products", "/products", "prefix"),
|
||||||
|
true,
|
||||||
|
"prefix itself"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/products/", "/products", "prefix"),
|
||||||
|
true,
|
||||||
|
"prefix slash"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/products/shoes", "/products", "prefix"),
|
||||||
|
true,
|
||||||
|
"prefix child"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/productsforsale", "/products", "prefix"),
|
||||||
|
false,
|
||||||
|
"prefix partial segment"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/products/shoes", "/products/", "prefix"),
|
||||||
|
true,
|
||||||
|
"prefix with trailing slash"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/products", "/products/", "prefix"),
|
||||||
|
false,
|
||||||
|
"trailing-slash prefix vs bare"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/other", "/products", "prefix"),
|
||||||
|
false,
|
||||||
|
"prefix miss"
|
||||||
|
);
|
||||||
|
|
||||||
|
// regex (unanchored, like PathRegexp)
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/api/v1/x", "^/api/.*", "regex"),
|
||||||
|
true,
|
||||||
|
"regex anchored"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/x/api/v1", "/api/", "regex"),
|
||||||
|
true,
|
||||||
|
"regex unanchored"
|
||||||
|
);
|
||||||
|
assertEquals(matchesPath("/foo", "^/api", "regex"), false, "regex miss");
|
||||||
|
assertEquals(
|
||||||
|
matchesPath("/foo", "(", "regex"),
|
||||||
|
false,
|
||||||
|
"invalid regex never matches"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("All matchesPath tests passed!");
|
||||||
|
|
||||||
|
console.log("Running priority tests...");
|
||||||
|
|
||||||
|
// Resource routers: explicit override, else derived from path specificity
|
||||||
|
assertEquals(computeRoutePriority(null, null, null), 100, "default");
|
||||||
|
assertEquals(computeRoutePriority(100, null, null), 100, "100 is auto");
|
||||||
|
assertEquals(computeRoutePriority(500, "/a", "exact"), 500, "explicit");
|
||||||
|
assertEquals(computeRoutePriority(null, "/a", "exact"), 115, "exact");
|
||||||
|
assertEquals(computeRoutePriority(null, "/a", "prefix"), 113, "prefix");
|
||||||
|
assertEquals(computeRoutePriority(null, "/a", "regex"), 112, "regex");
|
||||||
|
assertEquals(computeRoutePriority(null, "/", "prefix"), 1, "catch-all");
|
||||||
|
|
||||||
|
// Redirect routers always land above any resource router (max 1000)
|
||||||
|
assertEquals(
|
||||||
|
computeRedirectPriority(null, null, null),
|
||||||
|
1100,
|
||||||
|
"redirect default"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
computeRedirectPriority(null, "/", "prefix"),
|
||||||
|
1001,
|
||||||
|
"redirect catch-all"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
computeRedirectPriority(1, null, null),
|
||||||
|
1001,
|
||||||
|
"redirect lowest explicit"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
computeRedirectPriority(1000, null, null),
|
||||||
|
2000,
|
||||||
|
"redirect highest explicit"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
computeRedirectPriority(1, "/", "prefix") >
|
||||||
|
computeRoutePriority(1000, "/a", "exact"),
|
||||||
|
true,
|
||||||
|
"weakest redirect beats strongest resource"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("All priority tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -40,6 +40,48 @@ export function appendPathMatch(
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side equivalent of the clause appendPathMatch emits, so badger can
|
||||||
|
* tell whether a request would have matched a given path config. Mirrors
|
||||||
|
* Traefik v3 semantics: Path is exact, PathPrefix is segment-aware
|
||||||
|
* (`/products` matches `/products/shoes` but not `/productsforsale`), and
|
||||||
|
* PathRegexp is an unanchored regex test.
|
||||||
|
*/
|
||||||
|
export function matchesPath(
|
||||||
|
requestPath: string,
|
||||||
|
path: string | null | undefined,
|
||||||
|
pathMatchType: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!path || !pathMatchType) return true;
|
||||||
|
|
||||||
|
if (pathMatchType === "regex") {
|
||||||
|
try {
|
||||||
|
return new RegExp(path).test(requestPath);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = path;
|
||||||
|
if (!p.startsWith("/")) {
|
||||||
|
p = `/${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathMatchType === "exact") {
|
||||||
|
return requestPath === p;
|
||||||
|
} else if (pathMatchType === "prefix") {
|
||||||
|
if (!requestPath.startsWith(p)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (p.endsWith("/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const rest = requestPath.slice(p.length);
|
||||||
|
return rest === "" || rest.startsWith("/");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the router priority for a resource, favoring an explicit override
|
* Compute the router priority for a resource, favoring an explicit override
|
||||||
* and otherwise deriving it from the path match specificity.
|
* and otherwise deriving it from the path match specificity.
|
||||||
@@ -69,3 +111,25 @@ export function computeRoutePriority(
|
|||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirects must always be evaluated before resource routers on the same
|
||||||
|
// host. Target and redirect priorities are both capped at 1000, so lifting
|
||||||
|
// every redirect by this offset puts them in a band (1001-2000) no resource
|
||||||
|
// router can reach, while explicit priorities still order redirects among
|
||||||
|
// themselves.
|
||||||
|
export const REDIRECT_PRIORITY_OFFSET = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the router priority for a redirect: the same derivation as a
|
||||||
|
* resource router, shifted into the redirect band.
|
||||||
|
*/
|
||||||
|
export function computeRedirectPriority(
|
||||||
|
priority: number | null | undefined,
|
||||||
|
path: string | null | undefined,
|
||||||
|
pathMatchType: string | null | undefined
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
computeRoutePriority(priority, path, pathMatchType) +
|
||||||
|
REDIRECT_PRIORITY_OFFSET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Reasons:
|
|||||||
107 - Valid SSO
|
107 - Valid SSO
|
||||||
108 - Connected Client
|
108 - Connected Client
|
||||||
109 - Valid Virtual API Key
|
109 - Valid Virtual API Key
|
||||||
|
110 - Allowed Redirect
|
||||||
|
|
||||||
201 - Resource Not Found
|
201 - Resource Not Found
|
||||||
202 - Resource Blocked
|
202 - Resource Blocked
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
verifyVirtualApiKey
|
verifyVirtualApiKey
|
||||||
} from "@server/auth/verifyVirtualApiKey";
|
} from "@server/auth/verifyVirtualApiKey";
|
||||||
import {
|
import {
|
||||||
|
type RedirectByHost,
|
||||||
|
getRedirectsByHost,
|
||||||
getResourceByDomain,
|
getResourceByDomain,
|
||||||
getResourceRules,
|
getResourceRules,
|
||||||
getRoleResourceAccess,
|
getRoleResourceAccess,
|
||||||
@@ -40,6 +42,8 @@ import {
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||||
|
import { matchesPath } from "@server/lib/traefik/rule";
|
||||||
|
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -67,6 +71,7 @@ import { APP_VERSION } from "@server/lib/consts";
|
|||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type ResponseT from "@server/types/MessageResponse";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string(), z.string()).optional(),
|
sessions: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -108,6 +113,9 @@ export type VerifyUserResponse = {
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
headerAuthChallenged?: boolean;
|
headerAuthChallenged?: boolean;
|
||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
|
// Set alongside redirectUrl when the redirect is a configured Redirect
|
||||||
|
// rather than a login bounce, so badger can answer 307 instead of 302.
|
||||||
|
redirectPermanent?: boolean;
|
||||||
userData?: BasicUserData;
|
userData?: BasicUserData;
|
||||||
pangolinVersion?: string;
|
pangolinVersion?: string;
|
||||||
dontStripSession?: boolean;
|
dontStripSession?: boolean;
|
||||||
@@ -192,6 +200,30 @@ export async function verifyResourceSession(
|
|||||||
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirects always win: they are routed ahead of resources in
|
||||||
|
// Traefik and never require auth, even when attached to a resource,
|
||||||
|
// so let a matching one through to the redirect middleware before
|
||||||
|
// any resource lookup.
|
||||||
|
const redirect = await findRedirect(cleanHost, path);
|
||||||
|
if (redirect) {
|
||||||
|
const redirectUrl = buildRedirectUrl(redirect, parsedBody.data);
|
||||||
|
logger.debug(
|
||||||
|
`Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 110, // redirected
|
||||||
|
orgId: redirect.orgId,
|
||||||
|
location: ipCC
|
||||||
|
},
|
||||||
|
parsedBody.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirected(res, redirectUrl, redirect.permanent);
|
||||||
|
}
|
||||||
|
|
||||||
const resourceCacheKey = `resource:${cleanHost}`;
|
const resourceCacheKey = `resource:${cleanHost}`;
|
||||||
let resourceData:
|
let resourceData:
|
||||||
| {
|
| {
|
||||||
@@ -199,9 +231,7 @@ export async function verifyResourceSession(
|
|||||||
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
||||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||||
headerAuth:
|
headerAuth:
|
||||||
| ResourceHeaderAuth
|
ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||||
| ResourcePolicyHeaderAuth
|
|
||||||
| null;
|
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
applyRules: boolean | null;
|
applyRules: boolean | null;
|
||||||
sso: boolean | null;
|
sso: boolean | null;
|
||||||
@@ -1007,6 +1037,71 @@ function extractResourceSessionToken(
|
|||||||
return latest.token;
|
return latest.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findRedirect(
|
||||||
|
host: string,
|
||||||
|
path: string
|
||||||
|
): Promise<RedirectByHost | null> {
|
||||||
|
const cacheKey = `redirects:${host}`;
|
||||||
|
let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey);
|
||||||
|
if (!candidates) {
|
||||||
|
candidates = await getRedirectsByHost(host);
|
||||||
|
localCache.set(cacheKey, candidates, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates come back highest priority first, matching the order
|
||||||
|
// Traefik evaluates the routers in.
|
||||||
|
return (
|
||||||
|
candidates.find((r) =>
|
||||||
|
matchesPath(path, r.matchPath, r.pathMatchType)
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destination for a configured redirect: the request's scheme and query are
|
||||||
|
* kept, the host is swapped for the destination domain and the path is run
|
||||||
|
* through the redirect's rewrite rules (if any).
|
||||||
|
*/
|
||||||
|
function buildRedirectUrl(
|
||||||
|
redirect: RedirectByHost,
|
||||||
|
request: VerifyResourceSessionSchema
|
||||||
|
): string {
|
||||||
|
const newPath = rewriteRequestPath(
|
||||||
|
request.path,
|
||||||
|
redirect.matchPath,
|
||||||
|
redirect.pathMatchType,
|
||||||
|
redirect.rewritePath,
|
||||||
|
redirect.rewritePathType
|
||||||
|
);
|
||||||
|
|
||||||
|
let search = "";
|
||||||
|
try {
|
||||||
|
search = new URL(request.originalRequestURL).search;
|
||||||
|
} catch {
|
||||||
|
// originalRequestURL is validated as a URL, so this is only defensive
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${request.scheme}://${redirect.destinationDomain}${newPath}${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like a notAllowed login bounce, but the destination is the configured
|
||||||
|
// redirect target rather than the auth page.
|
||||||
|
function redirected(res: Response, redirectUrl: string, permanent: boolean) {
|
||||||
|
const data = {
|
||||||
|
data: {
|
||||||
|
valid: false,
|
||||||
|
redirectUrl,
|
||||||
|
redirectPermanent: permanent,
|
||||||
|
pangolinVersion: APP_VERSION
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Redirected",
|
||||||
|
status: HttpCode.OK
|
||||||
|
} satisfies ResponseT<VerifyUserResponse>;
|
||||||
|
return response<VerifyUserResponse>(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
async function notAllowed(
|
async function notAllowed(
|
||||||
res: Response,
|
res: Response,
|
||||||
redirectPath?: string,
|
redirectPath?: string,
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export default function GeneralPage() {
|
|||||||
// 107 - Valid SSO
|
// 107 - Valid SSO
|
||||||
// 108 - Connected Client
|
// 108 - Connected Client
|
||||||
// 109 - Valid Virtual API Key
|
// 109 - Valid Virtual API Key
|
||||||
|
// 110 - Allowed Redirect
|
||||||
|
|
||||||
// 201 - Resource Not Found
|
// 201 - Resource Not Found
|
||||||
// 202 - Resource Blocked
|
// 202 - Resource Blocked
|
||||||
@@ -300,6 +301,7 @@ export default function GeneralPage() {
|
|||||||
107: t("validSSO"),
|
107: t("validSSO"),
|
||||||
108: t("connectedClient"),
|
108: t("connectedClient"),
|
||||||
109: t("validVirtualAPIKey"),
|
109: t("validVirtualAPIKey"),
|
||||||
|
110: t("allowedRedirect"),
|
||||||
201: t("resourceNotFound"),
|
201: t("resourceNotFound"),
|
||||||
202: t("resourceBlocked"),
|
202: t("resourceBlocked"),
|
||||||
203: t("droppedByRule"),
|
203: t("droppedByRule"),
|
||||||
@@ -605,6 +607,7 @@ export default function GeneralPage() {
|
|||||||
{ value: "106", label: t("validEmail") },
|
{ value: "106", label: t("validEmail") },
|
||||||
{ value: "107", label: t("validSSO") },
|
{ value: "107", label: t("validSSO") },
|
||||||
{ value: "108", label: t("connectedClient") },
|
{ value: "108", label: t("connectedClient") },
|
||||||
|
{ value: "110", label: t("allowedRedirect") },
|
||||||
{ value: "201", label: t("resourceNotFound") },
|
{ value: "201", label: t("resourceNotFound") },
|
||||||
{ value: "202", label: t("resourceBlocked") },
|
{ value: "202", label: t("resourceBlocked") },
|
||||||
{ value: "203", label: t("droppedByRule") },
|
{ value: "203", label: t("droppedByRule") },
|
||||||
|
|||||||
Reference in New Issue
Block a user