From 0b6f9a66560e11747e6cc7c9cde00372022c068b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Jul 2026 14:04:33 -0400 Subject: [PATCH 1/6] support persistent session and users in access tokens --- server/auth/sessions/resource.ts | 72 ++++-- server/auth/verifyResourceAccessToken.ts | 46 +++- server/lib/logAccessAudit.ts | 49 ++++ server/lib/types.ts | 11 + server/routers/badger/verifySession.ts | 288 ++++++++++++++++++----- 5 files changed, 394 insertions(+), 72 deletions(-) create mode 100644 server/lib/logAccessAudit.ts diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 9b8d7ab..0da0992 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -8,30 +8,74 @@ export async function validateResourceSessionToken( token: string, resourceId: number ): Promise { - try { - const response = await axios.post(`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/${resourceId}/session/validate`, { + try { + const response = await axios.post( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/${resourceId}/session/validate`, + { token: token - }, await tokenManager.getAuthHeader()); - return response.data.data; - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error("Error validating resource session token in hybrid mode:", { + }, + await tokenManager.getAuthHeader() + ); + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + "Error validating resource session token in hybrid mode:", + { message: error.message, code: error.code, status: error.response?.status, statusText: error.response?.statusText, url: error.config?.url, method: error.config?.method - }); - } else { - logger.error("Error validating resource session token in hybrid mode:", error); - } - return { resourceSession: null }; + } + ); + } else { + logger.error( + "Error validating resource session token in hybrid mode:", + error + ); } + return { resourceSession: null }; + } +} + +export async function createAccessTokenResourceSession( + resourceId: number, + accessTokenId: string +): Promise { + try { + const response = await axios.post( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/${resourceId}/session/create-access-token`, + { + accessTokenId + }, + await tokenManager.getAuthHeader() + ); + return response.data.data?.cookie ?? null; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error( + "Error creating access token resource session in hybrid mode:", + { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + } + ); + } else { + logger.error( + "Error creating access token resource session in hybrid mode:", + error + ); + } + return null; + } } export type ResourceSessionValidationResult = { resourceSession: ResourceSession | null; }; - - diff --git a/server/auth/verifyResourceAccessToken.ts b/server/auth/verifyResourceAccessToken.ts index da13b44..50b1b7e 100644 --- a/server/auth/verifyResourceAccessToken.ts +++ b/server/auth/verifyResourceAccessToken.ts @@ -2,7 +2,11 @@ import { tokenManager } from "@server/lib/tokenManager"; import logger from "@server/logger"; import axios from "axios"; import config from "@server/lib/config"; -import { Resource, ResourceAccessToken } from "@server/lib/types"; +import { + AccessTokenUserData, + Resource, + ResourceAccessToken +} from "@server/lib/types"; export async function verifyResourceAccessToken({ accessToken, @@ -17,10 +21,14 @@ export async function verifyResourceAccessToken({ error?: string; tokenItem?: ResourceAccessToken; resource?: Resource; + userData?: AccessTokenUserData; }> { try { + const path = resourceId + ? `resource/${resourceId}/access-token/verify` + : `resource/access-token/verify`; const response = await axios.post( - `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/access-token/verify`, + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/${path}`, { accessToken: accessToken, accessTokenId: accessTokenId, @@ -32,7 +40,7 @@ export async function verifyResourceAccessToken({ } catch (error) { if (axios.isAxiosError(error)) { logger.error( - "Error validating resource session token in hybrid mode:", + "Error validating resource access token in hybrid mode:", { message: error.message, code: error.code, @@ -44,10 +52,40 @@ export async function verifyResourceAccessToken({ ); } else { logger.error( - "Error validating resource session token in hybrid mode:", + "Error validating resource access token in hybrid mode:", error ); } return { valid: false }; } } + +export async function getResourceAccessToken(accessTokenId: string): Promise<{ + tokenItem?: ResourceAccessToken; + userData?: AccessTokenUserData; +} | null> { + try { + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/resource/access-token/${accessTokenId}`, + await tokenManager.getAuthHeader() + ); + return response.data.data; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error("Error getting resource access token in hybrid mode:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error( + "Error getting resource access token in hybrid mode:", + error + ); + } + return null; + } +} diff --git a/server/lib/logAccessAudit.ts b/server/lib/logAccessAudit.ts new file mode 100644 index 0000000..9799323 --- /dev/null +++ b/server/lib/logAccessAudit.ts @@ -0,0 +1,49 @@ +import config from "@server/lib/config"; +import { tokenManager } from "@server/lib/tokenManager"; +import logger from "@server/logger"; +import axios from "axios"; + +export async function logAccessAudit(data: { + action: boolean; + type: string; + orgId: string; + resourceId?: number; + siteResourceId?: number; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + userAgent?: string; + requestIp?: string; +}) { + try { + const endpoint = config.getRawConfig().managed?.endpoint; + if (!endpoint) { + logger.warn( + "Cannot send access audit log: managed endpoint not configured" + ); + return; + } + + await axios.post( + `${endpoint}/api/v1/hybrid/logs/access`, + data, + await tokenManager.getAuthHeader() + ); + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error("logAccessAudit: Error sending access audit log:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error( + "logAccessAudit: Error sending access audit log:", + error + ); + } + } +} diff --git a/server/lib/types.ts b/server/lib/types.ts index 9c38d96..13900db 100644 --- a/server/lib/types.ts +++ b/server/lib/types.ts @@ -113,10 +113,21 @@ export type ResourceAccessToken = { accessTokenId: string; orgId: string; resourceId: number; + userId: string | null; + path: string | null; tokenHash: string; sessionLength: number; expiresAt: number | null; title: string | null; description: string | null; + persistSession: boolean; createdAt: number; }; + +export type AccessTokenUserData = { + userId: string; + username: string; + email: string | null; + name: string | null; + role: string | null; +}; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0c2e792..635a01c 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,4 +1,7 @@ -import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { + validateResourceSessionToken, + createAccessTokenResourceSession +} from "@server/auth/sessions/resource"; import { getResourceByDomain, getResourceRules, @@ -25,6 +28,8 @@ import { verifyPassword } from "@server/auth/password"; import { Org, Resource, + ResourceAccessToken, + AccessTokenUserData, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, @@ -32,8 +37,12 @@ import { ResourceRule, ResourceSession } from "@server/lib/types"; -import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { + getResourceAccessToken, + verifyResourceAccessToken +} from "@server/auth/verifyResourceAccessToken"; import { logRequestAudit } from "./logRequestAudit"; +import { logAccessAudit } from "@server/lib/logAccessAudit"; import { remoteGetASNForIp } from "@server/lib/asn"; import { APP_VERSION } from "@server/lib/consts"; import { enforceResourceSessionLength } from "@server/lib/checkOrgAccessPolicy"; @@ -318,13 +327,16 @@ export async function verifyResourceSession( config.getRemoteConfig().resource_access_token_headers.token ]; - const { valid, error, tokenItem } = await verifyResourceAccessToken( - { - accessToken, - accessTokenId, - resourceId: resource.resourceId - } - ); + const { + valid, + error, + tokenItem, + userData + } = await verifyResourceAccessToken({ + accessToken, + accessTokenId, + resourceId: resource.resourceId + }); if (error) { logger.debug("Access token invalid: " + error); @@ -341,22 +353,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + parsedBody.data, + ipCC, + userData ); - - return allowed(res); } } @@ -369,13 +374,16 @@ export async function verifyResourceSession( const [accessTokenId, accessToken] = token.split("."); - const { valid, error, tokenItem } = await verifyResourceAccessToken( - { - accessToken, - accessTokenId, - resourceId: resource.resourceId - } - ); + const { + valid, + error, + tokenItem, + userData + } = await verifyResourceAccessToken({ + accessToken, + accessTokenId, + resourceId: resource.resourceId + }); if (error) { logger.debug("Access token invalid: " + error); @@ -392,22 +400,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + parsedBody.data, + ipCC, + userData ); - - return allowed(res); } } @@ -657,22 +658,25 @@ export async function verifyResourceSession( "Resource allowed because access token session is valid" ); - logRequestAudit( + const tokenData = await getResourceAccessToken( + resourceSession.accessTokenId + ); + const tokenItem = tokenData?.tokenItem; + const userData = tokenData?.userData; + + logAccessTokenRequestAudit( { - action: true, - reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, - apiKey: { - name: null, - apiKeyId: resourceSession.accessTokenId - } + accessTokenId: resourceSession.accessTokenId, + tokenTitle: tokenItem?.title ?? null, + userData }, parsedBody.data ); - return allowed(res); + return allowed(res, userData); } if (resourceSession.userSessionId && sso) { @@ -877,6 +881,182 @@ function allowed(res: Response, userData?: BasicUserData) { return response(res, data); } +async function allowAccessToken( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + sessions: Record | undefined, + auditBody: VerifyResourceSessionSchema, + location?: string, + userData?: AccessTokenUserData +) { + logAccessTokenRequestAudit( + { + resourceId: resource.resourceId, + orgId: resource.orgId, + location, + accessTokenId: tokenItem.accessTokenId, + tokenTitle: tokenItem.title, + userData + }, + auditBody + ); + + if (!tokenItem.persistSession) { + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return allowed(res, userData); + } + + const resourceSessionToken = extractResourceSessionToken( + sessions ?? {}, + resource.ssl + ); + + if (resourceSessionToken) { + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: ResourceSession | null | undefined = + localCache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + resourceSession = result?.resourceSession; + localCache.set(sessionCacheKey, resourceSession, 5); + } + + if ( + resourceSession && + !resourceSession.isRequestToken && + resourceSession.accessTokenId === tokenItem.accessTokenId + ) { + logger.debug( + "Resource allowed because existing access token session is valid" + ); + return allowed(res, userData); + } + } + + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return await createAccessTokenSession(res, resource, tokenItem, userData); +} + +async function createAccessTokenSession( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + userData?: BasicUserData +) { + const cookie = await createAccessTokenResourceSession( + resource.resourceId, + tokenItem.accessTokenId + ); + + if (cookie) { + res.appendHeader("Set-Cookie", cookie); + logger.debug("Access token is valid, creating new session"); + } else { + logger.error( + "Failed to create access token session via hybrid endpoint" + ); + } + + return allowed(res, userData); +} + +function logAccessTokenRequestAudit( + data: { + resourceId: number; + orgId: string; + location?: string; + accessTokenId: string; + tokenTitle: string | null; + userData?: BasicUserData; + }, + body: VerifyResourceSessionSchema +) { + if (data.userData) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + user: { + username: data.userData.username, + userId: data.userData.userId + }, + metadata: { + accessTokenId: data.accessTokenId, + accessTokenTitle: data.tokenTitle + } + }, + body + ); + return; + } + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + apiKey: { + name: data.tokenTitle, + apiKeyId: data.accessTokenId + } + }, + body + ); +} + +function logAccessTokenAccessAudit( + tokenItem: ResourceAccessToken, + resource: Resource, + userData: BasicUserData | undefined, + body: VerifyResourceSessionSchema +) { + const userAgent = + body.headers?.["user-agent"] || body.headers?.["User-Agent"]; + + if (userData) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + user: { + username: userData.username, + userId: userData.userId + }, + metadata: { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + }, + userAgent, + requestIp: body.requestIp + }); + return; + } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + userAgent, + requestIp: body.requestIp + }); +} + async function headerAuthChallenged( res: Response, redirectPath?: string, From 4c6b51f28908fcb7e163aa62359f6161d7782a77 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 18 Jul 2026 17:29:12 -0400 Subject: [PATCH 2/6] fix cache not including resource id --- server/routers/badger/verifySession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 635a01c..268da2d 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -413,7 +413,7 @@ export async function verifyResourceSession( } // check for HTTP Basic Auth header - const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; + const clientHeaderAuthKey = `headerAuth:${resource.resourceId}:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { if (localCache.get(clientHeaderAuthKey)) { logger.debug( From 7636bbbe487690c68510d5b27e587a0a3a0b7212 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Jul 2026 14:51:26 -0400 Subject: [PATCH 3/6] Resovle endcoding issue --- server/routers/badger/verifySession.ts | 37 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 268da2d..f90ee85 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1275,13 +1275,44 @@ async function checkRules( return; } +// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a +// real path separator, matching what most backends will do) and then +// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` +// or `/public/../admin/` is matched as `/admin/`, not as a literal segment +// or a wildcard-swallowed sequence under `/public/*`. +function decodeAndResolvePath(p: string): string[] { + const rawParts = p.split("/").filter(Boolean); + + const resolved: string[] = []; + for (const rawPart of rawParts) { + let part: string; + try { + part = decodeURIComponent(rawPart); + } catch { + part = rawPart; + } + + // an encoded slash can turn one raw segment into several real ones + for (const segment of part.split("/").filter(Boolean)) { + if (segment === ".") { + continue; + } else if (segment === "..") { + resolved.pop(); + } else { + resolved.push(segment); + } + } + } + + return resolved; +} + export function isPathAllowed(pattern: string, path: string): boolean { logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); // Normalize and split paths into segments - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = decodeAndResolvePath(path); logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); From e98f0c70a6e5702054086d8f0ac2017a48c7f877 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 10:09:48 -0400 Subject: [PATCH 4/6] Dont redirect when the browser agent is not real --- server/routers/badger/verifySession.ts | 54 ++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index f90ee85..9112dbd 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -122,6 +122,9 @@ export async function verifyResourceSession( // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); + const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"]; + const clientIsBrowser = isBrowserUserAgent(clientUserAgent); + const clientIp = requestIp ? stripPortFromHost(requestIp, badgerVersion) : undefined; @@ -304,9 +307,14 @@ export async function verifyResourceSession( return allowed(res); } - const redirectPath = `/auth/resource/${encodeURIComponent( - resource.resourceGuid - )}?redirect=${encodeURIComponent(originalRequestURL)}`; + // Only offer a browser redirect to clients that can actually follow one and log in + // (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get + // an unauthorized response from Badger instead of a login redirect URL. + const redirectPath = clientIsBrowser + ? `/auth/resource/${encodeURIComponent( + resource.resourceGuid + )}?redirect=${encodeURIComponent(originalRequestURL)}` + : undefined; // check for access token in headers if ( @@ -1585,6 +1593,46 @@ async function getCountryCodeFromIp(ip: string): Promise { return cachedCountryCode; } +// Permissive by default: only reject known non-browser clients or a missing +// User-Agent (real browsers always send one). This avoids blocking real +// browsers whose UA string doesn't match a hardcoded allow-list. +const NON_BROWSER_USER_AGENT_PATTERNS = [ + /curl/, + /wget/, + /python-requests/, + /python-urllib/, + /go-http-client/, + /okhttp/, + /axios/, + /node-fetch/, + /postmanruntime/, + /insomnia/, + /libwww-perl/, + /java\//, + /ruby/, + /php/, + /bot/, + /spider/, + /crawler/, + /headlesschrome/, + /phantomjs/, + /httpclient/, + /prometheus/, + /go-resty/, + /apache-httpclient/, + /scrapy/ +]; + +function isBrowserUserAgent(userAgent: string | undefined): boolean { + if (!userAgent) { + return false; + } + + const ua = userAgent.toLowerCase(); + + return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua)); +} + function extractBasicAuth( headers: Record | undefined ): string | undefined { From fb4bbf234289a45da27788669cd2b93781798d36 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 24 Aug 2026 10:55:38 -0400 Subject: [PATCH 5/6] Update traefik --- docker-compose.example.yml | 2 +- install/config/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 9a5dd9c..b4f69c0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -37,7 +37,7 @@ services: - 80:80 traefik: - image: docker.io/traefik:v3.5 + image: docker.io/traefik:v3.7 container_name: traefik restart: unless-stopped network_mode: service:gerbil diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index cea2225..5353fe2 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -37,7 +37,7 @@ services: - 80:80 traefik: - image: docker.io/traefik:v3.5 + image: docker.io/traefik:v3.7 container_name: traefik restart: unless-stopped network_mode: service:gerbil From 1396ba0df45d849c00e6d36cf18766ce3d393c4b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 31 Aug 2026 10:30:27 -0400 Subject: [PATCH 6/6] Add audit logging configuration and checks in logAccessAudit and logRequestAudit --- server/lib/logAccessAudit.ts | 4 ++++ server/lib/readConfigFile.ts | 3 ++- server/routers/badger/logRequestAudit.ts | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/lib/logAccessAudit.ts b/server/lib/logAccessAudit.ts index 9799323..bbbffed 100644 --- a/server/lib/logAccessAudit.ts +++ b/server/lib/logAccessAudit.ts @@ -15,6 +15,10 @@ export async function logAccessAudit(data: { userAgent?: string; requestIp?: string; }) { + if (!config.getRawConfig().app.audit_logging_enabled) { + return; + } + try { const endpoint = config.getRawConfig().managed?.endpoint; if (!endpoint) { diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 4d30ca9..3bc5aa6 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -15,7 +15,8 @@ export const configSchema = z.object({ log_level: z .enum(["debug", "info", "warn", "error"]) .default("info"), - log_failed_attempts: z.boolean().optional().default(false) + log_failed_attempts: z.boolean().optional().default(false), + audit_logging_enabled: z.boolean().optional().default(true) }) .prefault({}), server: z diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index b9d0641..bccb3b2 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -135,6 +135,10 @@ export async function logRequestAudit( requestIp?: string; } ) { + if (!config.getRawConfig().app.audit_logging_enabled) { + return; + } + try { if (data.orgId) { const retentionDays = await getRetentionDays(data.orgId);