mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-19 00:59:53 +02:00
✨ handle redirect request in badger's verifySession
This commit is contained in:
+2
-1
@@ -3540,6 +3540,7 @@
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"validVirtualAPIKey": "Valid Virtual API Key",
|
||||
"allowedRedirect": "Allowed Redirect",
|
||||
"view": "View",
|
||||
"configManaged": "Config Managed",
|
||||
"connectedClient": "Connected Client",
|
||||
@@ -4394,7 +4395,7 @@
|
||||
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
||||
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
|
||||
"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",
|
||||
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
||||
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
||||
|
||||
@@ -35,10 +35,12 @@ import {
|
||||
resourcePolicyHeaderAuth,
|
||||
ResourcePolicyHeaderAuth,
|
||||
resourceWhitelist,
|
||||
resourcePolicyWhiteList
|
||||
resourcePolicyWhiteList,
|
||||
redirects,
|
||||
domains
|
||||
} 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";
|
||||
|
||||
export type ResourceWithAuth = {
|
||||
@@ -53,11 +55,74 @@ export type ResourceWithAuth = {
|
||||
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 = {
|
||||
session: 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
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import config from "@server/lib/config";
|
||||
import {
|
||||
buildHostRule,
|
||||
appendPathMatch,
|
||||
computeRoutePriority
|
||||
computeRedirectPriority
|
||||
} from "@server/lib/traefik/rule";
|
||||
|
||||
export type RedirectRouteRow = {
|
||||
@@ -97,17 +97,11 @@ export function buildRedirectConfig(params: {
|
||||
redirect.pathMatchType
|
||||
);
|
||||
|
||||
// A redirect attached to a resource must win over that resource's
|
||||
// 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.matchPath,
|
||||
redirect.pathMatchType
|
||||
) + (hasExplicitPriority ? 0 : 1);
|
||||
const priority = computeRedirectPriority(
|
||||
redirect.priority,
|
||||
redirect.matchPath,
|
||||
redirect.pathMatchType
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and otherwise deriving it from the path match specificity.
|
||||
@@ -69,3 +111,25 @@ export function computeRoutePriority(
|
||||
}
|
||||
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
|
||||
108 - Connected Client
|
||||
109 - Valid Virtual API Key
|
||||
110 - Allowed Redirect
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
verifyVirtualApiKey
|
||||
} from "@server/auth/verifyVirtualApiKey";
|
||||
import {
|
||||
type RedirectByHost,
|
||||
getRedirectsByHost,
|
||||
getResourceByDomain,
|
||||
getResourceRules,
|
||||
getRoleResourceAccess,
|
||||
@@ -40,6 +42,8 @@ import {
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
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 logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -67,6 +71,7 @@ import { APP_VERSION } from "@server/lib/consts";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type ResponseT from "@server/types/MessageResponse";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
sessions: z.record(z.string(), z.string()).optional(),
|
||||
@@ -108,6 +113,9 @@ export type VerifyUserResponse = {
|
||||
valid: boolean;
|
||||
headerAuthChallenged?: boolean;
|
||||
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;
|
||||
pangolinVersion?: string;
|
||||
dontStripSession?: boolean;
|
||||
@@ -192,6 +200,30 @@ export async function verifyResourceSession(
|
||||
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}`;
|
||||
let resourceData:
|
||||
| {
|
||||
@@ -199,9 +231,7 @@ export async function verifyResourceSession(
|
||||
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth:
|
||||
| ResourceHeaderAuth
|
||||
| ResourcePolicyHeaderAuth
|
||||
| null;
|
||||
ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
@@ -1007,6 +1037,71 @@ function extractResourceSessionToken(
|
||||
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(
|
||||
res: Response,
|
||||
redirectPath?: string,
|
||||
|
||||
@@ -281,6 +281,7 @@ export default function GeneralPage() {
|
||||
// 107 - Valid SSO
|
||||
// 108 - Connected Client
|
||||
// 109 - Valid Virtual API Key
|
||||
// 110 - Allowed Redirect
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
@@ -300,6 +301,7 @@ export default function GeneralPage() {
|
||||
107: t("validSSO"),
|
||||
108: t("connectedClient"),
|
||||
109: t("validVirtualAPIKey"),
|
||||
110: t("allowedRedirect"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
@@ -605,6 +607,7 @@ export default function GeneralPage() {
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "108", label: t("connectedClient") },
|
||||
{ value: "110", label: t("allowedRedirect") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
|
||||
Reference in New Issue
Block a user