🔐 fix: Gate Shared Startup Config By Link Access (#13897)

* fix: gate shared startup config by link access

* fix: satisfy shared config CI checks

* fix: align shared config client types

* fix: reject expired shared link access
This commit is contained in:
Danny Avila 2026-06-23 08:28:37 -04:00 • committed by GitHub
parent ebb4f15dbe
commit e807c63d5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 276 additions and 22 deletions

View file

@ -185,18 +185,22 @@ describe('GET /api/config', () => {
expect(response.body).not.toHaveProperty('customFooter');
});
it('should include public share footer fields when share context is requested', async () => {
it('should not include share-only 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';
process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test';
process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test';
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('analyticsGtmId');
expect(response.body).not.toHaveProperty('customFooter');
expect(response.body).not.toHaveProperty('bundlerURL');
expect(response.body).not.toHaveProperty('staticBundlerURL');
expect(response.body).not.toHaveProperty('helpAndFaqURL');
expect(response.body).not.toHaveProperty('allowAccountDeletion');
});

View file

@ -6,6 +6,10 @@ const mockGetSharedLinkExpiration = jest.fn();
const mockGrantCreationPermissions = jest.fn();
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
const mockBuildSharedLinkStartupPayload = jest.fn();
const mockCanAccessSharedLink = jest.fn((_req, _res, next) => next());
const mockGetAppConfig = jest.fn();
const mockGetTenantId = jest.fn(() => undefined);
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => true),
@ -16,6 +20,7 @@ jest.mock('@librechat/api', () => ({
ensureLinkPermissions: jest.fn(),
isFileSnapshotEnabled: jest.fn(() => true),
isFileSnapshotKillSwitchActive: jest.fn(() => false),
buildSharedLinkStartupPayload: (...args) => mockBuildSharedLinkStartupPayload(...args),
deleteSharedLinkWithCleanup: jest.fn(),
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
@ -23,9 +28,11 @@ jest.mock('@librechat/api', () => ({
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn(), warn: jest.fn() },
getTenantId: (...args) => mockGetTenantId(...args),
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
runAsSystem: jest.fn((fn) => fn()),
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
SYSTEM_TENANT_ID: '__SYSTEM__',
}));
jest.mock('librechat-data-provider', () => ({
@ -84,11 +91,19 @@ jest.mock('~/server/utils/files', () => ({
getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`),
}));
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
jest.mock(
'~/server/middleware/canAccessSharedLink',
() =>
(...args) =>
mockCanAccessSharedLink(...args),
);
jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next());
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next());
jest.mock('~/server/services/Config/app', () => ({
getAppConfig: (...args) => mockGetAppConfig(...args),
}));
const { Readable } = require('stream');
const { RetentionMode } = require('librechat-data-provider');
@ -129,9 +144,22 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
return app;
};
describe('share routes retention', () => {
describe('share routes', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetTenantId.mockReturnValue(undefined);
mockGetAppConfig.mockResolvedValue({
interfaceConfig: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
},
});
mockBuildSharedLinkStartupPayload.mockReturnValue({
appTitle: 'Shared Chat',
bundlerURL: 'https://bundler.example.com',
interface: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
},
});
getRoleByName.mockResolvedValue({
permissions: {
SHARED_LINKS: {
@ -142,6 +170,45 @@ describe('share routes retention', () => {
mockGrantCreationPermissions.mockResolvedValue(undefined);
});
it('serves shared startup config after shared-link access is granted', async () => {
const response = await request(buildApp()).get('/api/share/share-123/config');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('private, no-store');
expect(mockCanAccessSharedLink).toHaveBeenCalled();
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
expect(mockBuildSharedLinkStartupPayload).toHaveBeenCalledWith({
interfaceConfig: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
},
});
expect(response.body).toEqual({
appTitle: 'Shared Chat',
bundlerURL: 'https://bundler.example.com',
interface: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
},
});
});
it('uses tenant-scoped app config for shared startup config when tenant context is present', async () => {
mockGetTenantId.mockReturnValue('tenant-abc');
const response = await request(buildApp()).get('/api/share/share-123/config');
expect(response.status).toBe(200);
expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' });
});
it('uses base app config for shared startup config in system context', async () => {
mockGetTenantId.mockReturnValue('__SYSTEM__');
const response = await request(buildApp()).get('/api/share/share-123/config');
expect(response.status).toBe(200);
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('prevents successful shared message responses from being cached', async () => {
getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });

View file

@ -107,9 +107,9 @@ function buildPreLoginPayload() {
}
/**
* 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.
* Fields shared by authenticated chat and share-view config. Anonymous share
* views receive these through `/api/share/:shareId/config` after share access
* checks, not through the generic startup config endpoint.
*/
function buildPublicSharePayload() {
/** @type {Partial<TStartupConfig>} */
@ -215,7 +215,6 @@ router.get('/', async function (req, res) {
/** @type {Partial<TStartupConfig>} */
const payload = {
...preLoginPayload,
...(req.query.context === 'share' ? publicSharePayload : {}),
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
...(rum ? { rum } : {}),

View file

@ -7,6 +7,7 @@ const {
ensureLinkPermissions,
isFileSnapshotEnabled,
isFileSnapshotKillSwitchActive,
buildSharedLinkStartupPayload,
deleteSharedLinkWithCleanup,
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
@ -14,8 +15,10 @@ const {
} = require('@librechat/api');
const {
logger,
getTenantId,
runAsSystem,
tenantStorage,
SYSTEM_TENANT_ID,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
@ -38,6 +41,7 @@ const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const configMiddleware = require('~/server/middleware/config/app');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
const checkSharedLinksAccess = generateCheckAccess({
@ -76,6 +80,14 @@ const runWithTenant = (tenantId, fn) =>
* 'failed' on the next poll so the client poller terminates. */
const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
const getShareStartupPayload = async () => {
const tenantId = getTenantId();
const appConfig = await getAppConfig(
tenantId && tenantId !== SYSTEM_TENANT_ID ? { tenantId } : { baseOnly: true },
);
return buildSharedLinkStartupPayload(appConfig);
};
/**
* MIME types that are safe to render inline. Everything else (text/html, SVG,
* and other active content) is served as an `attachment` so a public viewer
@ -212,6 +224,17 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
};
if (allowSharedLinks) {
router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => {
try {
const payload = await getShareStartupPayload();
res.set('Cache-Control', 'private, no-store');
res.status(200).json(payload);
} catch (error) {
logger.error('Error getting shared startup config:', error);
res.status(500).json({ message: 'Error getting shared startup config' });
}
});
router.get(
'/:shareId',
optionalJwtAuth,

View file

@ -4,7 +4,7 @@ import type {
SandpackProviderProps,
SandpackPreviewRef,
} from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { SandpackStartupConfig } from '~/utils/artifacts';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, buildSandpackOptions } from '~/utils/artifacts';
@ -23,7 +23,7 @@ export const ArtifactPreview = memo(function ({
sharedProps: Partial<SandpackProviderProps>;
previewRef: MutableRefObject<SandpackPreviewRef>;
currentCode?: string;
startupConfig?: TStartupConfig;
startupConfig?: SandpackStartupConfig;
}) {
const artifactFiles = useMemo(() => {
if (Object.keys(files).length === 0) {

View file

@ -3,11 +3,12 @@ import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useCodeState } from '~/Providers/EditorContext';
import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { useCodeState } from '~/Providers/EditorContext';
import { ArtifactPreview } from './ArtifactPreview';
import { useShareContext } from '~/Providers';
export default function ArtifactTabs({
artifact,
@ -19,7 +20,14 @@ export default function ArtifactTabs({
isSharedConvo?: boolean;
}) {
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();
const { shareId } = useShareContext();
const shouldUseSharedConfig =
isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0;
const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig });
const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, {
enabled: shouldUseSharedConfig,
});
const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig;
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null);
@ -55,7 +63,7 @@ export default function ArtifactTabs({
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={startupConfig}
startupConfig={resolvedStartupConfig}
/>
</Tabs.Content>
</div>

View file

@ -8,7 +8,11 @@ import { useLocalize } from '~/hooks';
type FooterProps = {
className?: string;
startupConfig?: TStartupConfig | null;
startupConfig?: FooterStartupConfig | null;
};
type FooterStartupConfig = Pick<Partial<TStartupConfig>, 'analyticsGtmId' | 'customFooter'> & {
interface?: Pick<NonNullable<TStartupConfig['interface']>, 'privacyPolicy' | 'termsOfService'>;
};
function Footer({ className, startupConfig }: FooterProps) {

View file

@ -18,9 +18,9 @@ import {
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/Selectors';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { useGetSharedStartupConfig } from '~/data-provider';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
@ -29,9 +29,9 @@ import store from '~/store';
function SharedView() {
const localize = useLocalize();
const { data: config } = useGetStartupConfig(undefined, { context: 'share' });
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams();
const { data: config } = useGetSharedStartupConfig(shareId);
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);

View file

@ -88,6 +88,9 @@ export const useContextProjectionQuery = (
export const startupConfigKey = (isAuthenticated: boolean, context?: t.StartupConfigContext) =>
[QueryKeys.startupConfig, isAuthenticated, context ?? 'default'] as const;
export const sharedStartupConfigKey = (shareId?: string) =>
[QueryKeys.sharedStartupConfig, shareId ?? ''] as const;
export const useGetStartupConfig = (
config?: UseQueryOptions<t.TStartupConfig>,
options?: { context?: t.StartupConfigContext },
@ -107,3 +110,26 @@ export const useGetStartupConfig = (
},
);
};
export const useGetSharedStartupConfig = (
shareId?: string,
config?: UseQueryOptions<t.TSharedLinkStartupConfig>,
): QueryObserverResult<t.TSharedLinkStartupConfig> => {
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
return useQuery<t.TSharedLinkStartupConfig>(
sharedStartupConfigKey(shareId),
() => dataService.getSharedStartupConfig(shareId ?? ''),
{
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
enabled:
(config?.enabled ?? true) === true &&
queriesEnabled &&
typeof shareId === 'string' &&
shareId.length > 0,
},
);
};

View file

@ -178,9 +178,14 @@ export const sharedOptions: SandpackProviderProps['options'] = {
externalResources: [TAILWIND_CDN],
};
export type SandpackStartupConfig = Pick<
Partial<TStartupConfig>,
'bundlerURL' | 'staticBundlerURL'
>;
export function buildSandpackOptions(
template: SandpackProviderProps['template'],
startupConfig?: TStartupConfig,
startupConfig?: SandpackStartupConfig,
): SandpackProviderProps['options'] {
if (!startupConfig) {
return sharedOptions;

View file

@ -124,6 +124,20 @@ describe('canAccessSharedLink', () => {
expect(res._status).toBe(404);
expect(next).not.toHaveBeenCalled();
});
test('returns 404 when share is expired but ACL still exists', async () => {
const link = await createTestLink({ expiredAt: new Date('2020-01-01T00:00:00.000Z') });
await grantPublicViewer(link._id);
process.env.ALLOW_SHARED_LINKS_PUBLIC = 'true';
const req = createReq({ params: { shareId: link.shareId } });
const res = createRes();
const next = jest.fn();
await canAccessSharedLink(req, res, next as unknown as NextFunction);
expect(res._status).toBe(404);
expect(next).not.toHaveBeenCalled();
});
});
describe('public links', () => {

View file

@ -1,5 +1,10 @@
import { ResourceType, PermissionBits } from 'librechat-data-provider';
import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas';
import {
getTenantId,
runAsSystem,
tenantStorage,
activeExpirationFilter,
} from '@librechat/data-schemas';
import type { Request, Response, NextFunction } from 'express';
import type { IUser } from '@librechat/data-schemas';
import type { Types, Model } from 'mongoose';
@ -53,7 +58,10 @@ export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) {
const SharedLink = mg.models.SharedLink as Model<RawSharedLink>;
const findShare = async () =>
(await SharedLink.findOne({ shareId }).lean()) as RawSharedLink | null;
(await SharedLink.findOne({
shareId,
...activeExpirationFilter<RawSharedLink>(),
}).lean()) as RawSharedLink | null;
const rawShare = getTenantId() ? await findShare() : await runAsSystem(findShare);
if (!rawShare) {

View file

@ -1,5 +1,5 @@
import type { AppConfig } from '@librechat/data-schemas';
import { isFileSnapshotEnabled } from './config';
import { buildSharedLinkStartupPayload, isFileSnapshotEnabled } from './config';
const withSharedLinks = (sharedLinks: unknown): AppConfig =>
({ interfaceConfig: { sharedLinks } }) as unknown as AppConfig;
@ -41,3 +41,45 @@ describe('isFileSnapshotEnabled', () => {
expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(true);
});
});
describe('buildSharedLinkStartupPayload', () => {
it('builds the share-view startup allowlist', () => {
const payload = buildSharedLinkStartupPayload(
{
interfaceConfig: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
termsOfService: { externalUrl: 'https://example.com/tos' },
modelSelect: true,
},
} as AppConfig,
{
ANALYTICS_GTM_ID: 'GTM-XYZ',
APP_TITLE: 'Test Chat',
CUSTOM_FOOTER: 'Shared footer',
SANDPACK_BUNDLER_URL: 'https://bundler.example.com',
SANDPACK_STATIC_BUNDLER_URL: 'https://static-bundler.example.com',
},
);
expect(payload).toEqual({
appTitle: 'Test Chat',
analyticsGtmId: 'GTM-XYZ',
bundlerURL: 'https://bundler.example.com',
staticBundlerURL: 'https://static-bundler.example.com',
customFooter: 'Shared footer',
interface: {
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
termsOfService: { externalUrl: 'https://example.com/tos' },
},
});
});
it('defaults the app title and omits unrelated interface config', () => {
const payload = buildSharedLinkStartupPayload(
{ interfaceConfig: { modelSelect: true } } as AppConfig,
{},
);
expect(payload).toEqual({ appTitle: 'LibreChat' });
});
});

View file

@ -1,6 +1,9 @@
import type { TSharedLinkStartupConfig } from 'librechat-data-provider';
import type { AppConfig } from '@librechat/data-schemas';
import { isEnabled } from '~/utils';
type SharedLinkStartupEnv = NodeJS.ProcessEnv;
/**
* Whether shared links should snapshot the files referenced by the shared chat
* snapshot. The `SHARED_LINKS_SNAPSHOT_FILES` env var overrides the yaml
@ -31,3 +34,35 @@ export function isFileSnapshotKillSwitchActive(): boolean {
const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES;
return envValue !== undefined && !isEnabled(envValue);
}
export function buildSharedLinkStartupPayload(
appConfig?: AppConfig | null,
env: SharedLinkStartupEnv = process.env,
): TSharedLinkStartupConfig {
const payload: TSharedLinkStartupConfig = {
appTitle: env.APP_TITLE || 'LibreChat',
};
if (typeof env.ANALYTICS_GTM_ID === 'string') {
payload.analyticsGtmId = env.ANALYTICS_GTM_ID;
}
if (typeof env.SANDPACK_BUNDLER_URL === 'string') {
payload.bundlerURL = env.SANDPACK_BUNDLER_URL;
}
if (typeof env.SANDPACK_STATIC_BUNDLER_URL === 'string') {
payload.staticBundlerURL = env.SANDPACK_STATIC_BUNDLER_URL;
}
if (typeof env.CUSTOM_FOOTER === 'string') {
payload.customFooter = env.CUSTOM_FOOTER;
}
const { privacyPolicy, termsOfService } = appConfig?.interfaceConfig ?? {};
if (privacyPolicy || termsOfService) {
payload.interface = {
...(privacyPolicy ? { privacyPolicy } : {}),
...(termsOfService ? { termsOfService } : {}),
};
}
return payload;
}

View file

@ -71,6 +71,7 @@ export const messagesBranch = () => `${messagesRoot}/branch`;
const shareRoot = `${BASE_URL}/api/share`;
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const sharedStartupConfig = (shareId: string) => `${shareMessages(shareId)}/config`;
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
export const getSharedLinks = (
pageSize: number,

View file

@ -1447,6 +1447,19 @@ export type TStartupConfig = {
};
};
export type TSharedLinkStartupInterface = Pick<
Partial<TInterfaceConfig>,
'privacyPolicy' | 'termsOfService'
>;
export type TSharedLinkStartupConfig = Pick<TStartupConfig, 'appTitle'> &
Pick<
Partial<TStartupConfig>,
'analyticsGtmId' | 'bundlerURL' | 'customFooter' | 'staticBundlerURL'
> & {
interface?: TSharedLinkStartupInterface;
};
export enum OCRStrategy {
MISTRAL_OCR = 'mistral_ocr',
CUSTOM_OCR = 'custom_ocr',

View file

@ -64,6 +64,10 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
return request.get(endpoints.shareMessages(shareId));
}
export function getSharedStartupConfig(shareId: string): Promise<config.TSharedLinkStartupConfig> {
return request.get(endpoints.sharedStartupConfig(shareId));
}
export const listSharedLinks = async (
params: q.SharedLinksListParams,
): Promise<q.SharedLinksResponse> => {

View file

@ -1,6 +1,7 @@
export enum QueryKeys {
messages = 'messages',
sharedMessages = 'sharedMessages',
sharedStartupConfig = 'sharedStartupConfig',
sharedLinks = 'sharedLinks',
allConversations = 'allConversations',
archivedConversations = 'archivedConversations',