From 6db059b8a963ab56ed32f45da9e343ccb4181234 Mon Sep 17 00:00:00 2001 From: ChrisJr404 Date: Sat, 30 May 2026 12:51:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20fix:=20Strip=20post-login=20fiel?= =?UTF-8?q?ds=20from=20unauthenticated=20/api/config=20response=20(#13102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔒 fix: Strip post-login fields from unauthenticated /api/config response Follow-up to #12490 reported in #12688. The unauthenticated /api/config response still included fields that are only consumed after login (helpAndFaqURL, sharedLinksEnabled, publicSharedLinksEnabled, showBirthdayIcon, analyticsGtmId, openidReuseTokens, allowAccountDeletion, customFooter, cloudFront). None of these are read by the auth pages (Login, Registration, RequestPasswordReset, ResetPassword, VerifyEmail, TwoFactorScreen, AuthLayout, Footer, SocialLoginRender). Split buildSharedPayload into two helpers: - buildPreLoginPayload returns only the fields the unauthenticated auth pages need (appTitle, server domain, social-login flags, OpenID/SAML labels and image URLs, registration/email/password-reset flags, minPasswordLength, ldap). - buildPostLoginPayload returns the post-login informational fields and is merged into the response only when req.user is present. Also move buildCloudFrontStartupConfig into the authenticated branch: useAppStartup is the only consumer and it runs after login. Tests updated: existing CloudFront and allowAccountDeletion assertions move to the authenticated context, and two new assertions cover the stripped fields (one for the post-login informational fields, one for cloudFront) in the unauthenticated context. Signed-off-by: ChrisJr404 * fix: Request share-context startup config * fix: Pass share startup config into footer --------- Signed-off-by: ChrisJr404 Co-authored-by: Danny Avila --- api/server/routes/__tests__/config.spec.js | 168 +++++++++++------- api/server/routes/config.js | 75 ++++++-- client/src/components/Chat/Footer.tsx | 12 +- client/src/components/Share/ShareView.tsx | 7 +- client/src/data-provider/Endpoints/queries.ts | 9 +- packages/data-provider/src/api-endpoints.ts | 4 +- packages/data-provider/src/config.ts | 2 + packages/data-provider/src/data-service.ts | 10 +- 8 files changed, 196 insertions(+), 91 deletions(-) diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index d7fbd04446..606b4ae8a1 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -101,6 +101,9 @@ afterEach(() => { delete process.env.SAML_CERT; delete process.env.SAML_SESSION_SECRET; delete process.env.ALLOW_ACCOUNT_DELETION; + delete process.env.ANALYTICS_GTM_ID; + delete process.env.CUSTOM_FOOTER; + delete process.env.HELP_AND_FAQ_URL; }); describe('GET /api/config', () => { @@ -156,9 +159,48 @@ describe('GET /api/config', () => { expect(response.body).not.toHaveProperty('bundlerURL'); expect(response.body).not.toHaveProperty('staticBundlerURL'); expect(response.body).not.toHaveProperty('sharePointFilePickerEnabled'); + expect(response.body).not.toHaveProperty('sharePointBaseUrl'); + expect(response.body).not.toHaveProperty('sharePointPickerGraphScope'); + expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope'); expect(response.body).not.toHaveProperty('conversationImportMaxFileSize'); }); + it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'internal footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('showBirthdayIcon'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('sharedLinksEnabled'); + expect(response.body).not.toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).not.toHaveProperty('analyticsGtmId'); + expect(response.body).not.toHaveProperty('openidReuseTokens'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + expect(response.body).not.toHaveProperty('customFooter'); + }); + + it('should include public share footer fields when share context is requested', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'public footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config?context=share'); + + expect(response.statusCode).toBe(200); + expect(response.body.analyticsGtmId).toBe('GTM-XYZ'); + expect(response.body.customFooter).toBe('public footer text'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + }); + it('should include socialLogins and turnstile from base config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(null); @@ -206,7 +248,7 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('serverDomain'); }); - it('should advertise CloudFront cookie refresh only when signed-cookie mode is active', async () => { + it('should omit CloudFront cookie refresh from unauthenticated response (#12688)', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); mockGetCloudFrontConfig.mockReturnValue({ domain: 'https://cdn.example.com', @@ -219,69 +261,9 @@ describe('GET /api/config', () => { const response = await request(app).get('/api/config'); - expect(response.body.cloudFront).toEqual({ - cookieRefresh: { - endpoint: '/api/auth/cloudfront/refresh', - domain: 'https://cdn.example.com', - }, - }); - }); - - it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'url', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - expect(response.body).not.toHaveProperty('cloudFront'); }); - it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'cookies', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body).not.toHaveProperty('cloudFront'); - }); - - it('should default allowAccountDeletion to true when env var is unset', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - - it('should set allowAccountDeletion to false when ALLOW_ACCOUNT_DELETION=false', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'false'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(false); - }); - - it('should set allowAccountDeletion to true when ALLOW_ACCOUNT_DELETION=true', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'true'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - it('should return 500 when getAppConfig throws', async () => { mockGetAppConfig.mockRejectedValue(new Error('Config service failure')); const app = createApp(null); @@ -395,6 +377,70 @@ describe('GET /api/config', () => { expect(response.body.conversationImportMaxFileSize).toBe(5000000); }); + it('should include post-login informational fields', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'authenticated footer text'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).toHaveProperty('helpAndFaqURL'); + expect(response.body).toHaveProperty('sharedLinksEnabled'); + expect(response.body).toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).toHaveProperty('showBirthdayIcon'); + expect(response.body).toHaveProperty('openidReuseTokens'); + expect(response.body.analyticsGtmId).toBe('GTM-XYZ'); + expect(response.body.customFooter).toBe('authenticated footer text'); + }); + + it('should advertise CloudFront cookie refresh when signed-cookie mode is active', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-private-key', + keyPairId: 'K123ABC', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.cloudFront).toEqual({ + cookieRefresh: { + endpoint: '/api/auth/cloudfront/refresh', + domain: 'https://cdn.example.com', + }, + }); + }); + + it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'url', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + + it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + it('should merge per-user balance override into config', async () => { mockGetAppConfig.mockResolvedValue({ ...baseAppConfig, diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 7038ac2f71..c9ffe67788 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -39,7 +39,14 @@ function isBirthday() { return today.getMonth() === 1 && today.getDate() === 11; } -function buildSharedPayload() { +/** + * Pre-login fields rendered by the unauthenticated login, registration, password-reset, + * and email-verification pages. Any field added here is readable by anonymous callers + * of `GET /api/config`, so keep this set strictly to what those pages need. + * + * See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`. + */ +function buildPreLoginPayload() { const isOpenIdEnabled = !!process.env.OPENID_CLIENT_ID && (isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) && @@ -83,19 +90,6 @@ function buildSharedPayload() { !!process.env.EMAIL_PASSWORD && !!process.env.EMAIL_FROM, passwordResetEnabled, - showBirthdayIcon: - isBirthday() || - isEnabled(process.env.SHOW_BIRTHDAY_ICON) || - process.env.SHOW_BIRTHDAY_ICON === '', - helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', - sharedLinksEnabled, - publicSharedLinksEnabled, - analyticsGtmId: process.env.ANALYTICS_GTM_ID, - openidReuseTokens, - /** Read inline (not module-level) for per-request evaluation and test isolation */ - allowAccountDeletion: - process.env.ALLOW_ACCOUNT_DELETION === undefined || - isEnabled(process.env.ALLOW_ACCOUNT_DELETION), }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); @@ -107,6 +101,20 @@ function buildSharedPayload() { payload.ldap = ldap; } + return payload; +} + +/** + * Public share fields rendered by `client/src/components/Share/ShareView.tsx`. + * They remain off the default anonymous config used by login screens, and are + * exposed to anonymous callers only when the client asks for share context. + */ +function buildPublicSharePayload() { + /** @type {Partial} */ + const payload = { + analyticsGtmId: process.env.ANALYTICS_GTM_ID, + }; + if (typeof process.env.CUSTOM_FOOTER === 'string') { payload.customFooter = process.env.CUSTOM_FOOTER; } @@ -114,6 +122,32 @@ function buildSharedPayload() { return payload; } +/** + * Post-login fields appended only when `req.user` is present. These describe the + * authenticated UX (account-settings links, share-link feature flags, birthday icon, + * openid token-reuse marker) and are not needed on the pre-login screens, so they + * are not exposed to unauthenticated callers. + */ +function buildPostLoginPayload() { + /** @type {Partial} */ + const payload = { + showBirthdayIcon: + isBirthday() || + isEnabled(process.env.SHOW_BIRTHDAY_ICON) || + process.env.SHOW_BIRTHDAY_ICON === '', + helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', + sharedLinksEnabled, + publicSharedLinksEnabled, + openidReuseTokens, + /** Read inline (not module-level) for per-request evaluation and test isolation */ + allowAccountDeletion: + process.env.ALLOW_ACCOUNT_DELETION === undefined || + isEnabled(process.env.ALLOW_ACCOUNT_DELETION), + }; + + return payload; +} + function buildBuildInfoPayload(interfaceConfig) { if (interfaceConfig?.buildInfo === false) { return undefined; @@ -168,8 +202,8 @@ function buildCloudFrontStartupConfig() { router.get('/', async function (req, res) { try { - const sharedPayload = buildSharedPayload(); - const cloudFront = buildCloudFrontStartupConfig(); + const preLoginPayload = buildPreLoginPayload(); + const publicSharePayload = buildPublicSharePayload(); const rum = getRumConfig(); if (!req.user) { @@ -178,10 +212,10 @@ router.get('/', async function (req, res) { /** @type {Partial} */ const payload = { - ...sharedPayload, + ...preLoginPayload, + ...(req.query.context === 'share' ? publicSharePayload : {}), socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins, turnstile: baseConfig?.turnstileConfig, - ...(cloudFront ? { cloudFront } : {}), ...(rum ? { rum } : {}), }; @@ -215,10 +249,13 @@ router.get('/', async function (req, res) { }); const balanceConfig = getBalanceConfig(appConfig); + const cloudFront = buildCloudFrontStartupConfig(); /** @type {TStartupConfig} */ const payload = { - ...sharedPayload, + ...preLoginPayload, + ...publicSharePayload, + ...buildPostLoginPayload(), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, turnstile: appConfig?.turnstileConfig, diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 541647a8d0..b841a98819 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -2,11 +2,19 @@ import React, { useEffect, memo } from 'react'; import TagManager from 'react-gtm-module'; import ReactMarkdown from 'react-markdown'; import { Constants } from 'librechat-data-provider'; +import type { TStartupConfig } from 'librechat-data-provider'; import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; -function Footer({ className }: { className?: string }) { - const { data: config } = useGetStartupConfig(); +type FooterProps = { + className?: string; + startupConfig?: TStartupConfig | null; +}; + +function Footer({ className, startupConfig }: FooterProps) { + const shouldFetchConfig = startupConfig === undefined; + const { data: fetchedConfig } = useGetStartupConfig({ enabled: shouldFetchConfig }); + const config = shouldFetchConfig ? fetchedConfig : startupConfig; const localize = useLocalize(); const privacyPolicy = config?.interface?.privacyPolicy; diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 00a0d36398..6424546a23 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -29,7 +29,7 @@ import store from '~/store'; function SharedView() { const localize = useLocalize(); - const { data: config } = useGetStartupConfig(); + const { data: config } = useGetStartupConfig(undefined, { context: 'share' }); const { theme, setTheme } = useContext(ThemeContext); const { shareId } = useParams(); const { data, isLoading } = useGetSharedMessages(shareId ?? ''); @@ -124,7 +124,10 @@ function SharedView() { const footer = (
-
+
); diff --git a/client/src/data-provider/Endpoints/queries.ts b/client/src/data-provider/Endpoints/queries.ts index 3ce3d35110..17527c6dc1 100644 --- a/client/src/data-provider/Endpoints/queries.ts +++ b/client/src/data-provider/Endpoints/queries.ts @@ -28,17 +28,18 @@ export const useGetEndpointsQuery = ( * (chat page) configs are cached independently, preventing stale * unauthenticated config from persisting after login. */ -export const startupConfigKey = (isAuthenticated: boolean) => - [QueryKeys.startupConfig, isAuthenticated] as const; +export const startupConfigKey = (isAuthenticated: boolean, context?: t.StartupConfigContext) => + [QueryKeys.startupConfig, isAuthenticated, context ?? 'default'] as const; export const useGetStartupConfig = ( config?: UseQueryOptions, + options?: { context?: t.StartupConfigContext }, ): QueryObserverResult => { const queriesEnabled = useRecoilValue(store.queriesEnabled); const user = useRecoilValue(store.user); return useQuery( - startupConfigKey(!!user), - () => dataService.getStartupConfig(), + startupConfigKey(!!user, options?.context), + () => dataService.getStartupConfig({ context: options?.context }), { staleTime: Infinity, refetchOnWindowFocus: false, diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 0a50f678d4..16516888ac 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -1,3 +1,4 @@ +import type { StartupConfigContext } from './config'; import type { AssistantsEndpoint } from './schemas'; import * as q from './types/queries'; import { ResourceType } from './accessPermissions'; @@ -212,7 +213,8 @@ export const mcpOAuthBind = (serverName: string) => `${BASE_URL}/api/mcp/${serve export const actionOAuthBind = (actionId: string) => `${BASE_URL}/api/actions/${actionId}/oauth/bind`; -export const config = () => `${BASE_URL}/api/config`; +export const config = (context?: StartupConfigContext) => + `${BASE_URL}/api/config${buildQuery({ context })}`; export const prompts = () => `${BASE_URL}/api/prompts`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 7e8f8b259f..a35542e714 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1081,6 +1081,8 @@ export type TRumConfig = { environment?: string; }; +export type StartupConfigContext = 'share'; + export type TStartupConfig = { appTitle: string; socialLogins?: string[]; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 4704a5bbe0..732d5041fe 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -228,12 +228,18 @@ export function cancelMCPOAuth(serverName: string): Promise; } > => { - return request.get(endpoints.config()); + return request.get(endpoints.config(options?.context)); }; export const getAIEndpoints = (): Promise => {