From 2cb5cea48f4d9af48454cef7d6dc0c26b717bf71 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 18 Sep 2026 20:29:52 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20handle=20redirect=20request=20in=20?= =?UTF-8?q?badger's=20`verifySession`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 +- server/db/queries/verifySessionQueries.ts | 69 ++++++++- server/lib/traefik/middleware.test.ts | 134 +++++++++++++++++ server/lib/traefik/middleware.ts | 72 +++++++++ server/lib/traefik/redirect.ts | 18 +-- server/lib/traefik/rule.test.ts | 138 ++++++++++++++++++ server/lib/traefik/rule.ts | 64 ++++++++ server/routers/badger/logRequestAudit.ts | 1 + server/routers/badger/verifySession.ts | 101 ++++++++++++- .../[orgId]/settings/logs/request/page.tsx | 3 + 10 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 server/lib/traefik/middleware.test.ts create mode 100644 server/lib/traefik/rule.test.ts diff --git a/messages/en-US.json b/messages/en-US.json index 01b88bc7f..e694f1009 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index a2ae61046..7db986226 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -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 { + // 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`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 */ diff --git a/server/lib/traefik/middleware.test.ts b/server/lib/traefik/middleware.test.ts new file mode 100644 index 000000000..2023cae3c --- /dev/null +++ b/server/lib/traefik/middleware.test.ts @@ -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); +} diff --git a/server/lib/traefik/middleware.ts b/server/lib/traefik/middleware.ts index 1c62442be..f96f855cf 100644 --- a/server/lib/traefik/middleware.ts +++ b/server/lib/traefik/middleware.ts @@ -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, "\\$&"); } diff --git a/server/lib/traefik/redirect.ts b/server/lib/traefik/redirect.ts index 8f1b81c7e..787b95509 100644 --- a/server/lib/traefik/redirect.ts +++ b/server/lib/traefik/redirect.ts @@ -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 diff --git a/server/lib/traefik/rule.test.ts b/server/lib/traefik/rule.test.ts new file mode 100644 index 000000000..47e21f85a --- /dev/null +++ b/server/lib/traefik/rule.test.ts @@ -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); +} diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts index 5f9406db4..66cd6defc 100644 --- a/server/lib/traefik/rule.ts +++ b/server/lib/traefik/rule.ts @@ -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 + ); +} diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 3fb97dce5..caf2d7823 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -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 diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index f50cf8cdc..59dacede1 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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 { + 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; + return response(res, data); +} + async function notAllowed( res: Response, redirectPath?: string, diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 378cdcad5..9ba33126d 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -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") },