handle redirect request in badger's verifySession

This commit is contained in:
Fred KISSIE
2026-09-18 20:29:52 +02:00
parent 2f5e3eede6
commit 2cb5cea48f
10 changed files with 585 additions and 18 deletions
+2 -1
View File
@@ -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",
+67 -2
View File
@@ -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
*/ */
+134
View File
@@ -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);
}
+72
View File
@@ -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 -9
View File
@@ -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
+138
View File
@@ -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);
}
+64
View File
@@ -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
);
}
+1
View File
@@ -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
+98 -3
View File
@@ -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") },