From 25f3841c64422c289f30743520cb3ca88cc471fb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 13 Sep 2026 23:21:49 -0400 Subject: [PATCH 1/3] fix: authenticate GitHub App Git operations (#192) --- packages/code/src/github.test.ts | 9 +++++++-- packages/code/src/github.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index bd62acfd..78d9751d 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -137,12 +137,17 @@ test('builds process-scoped Git HTTPS authorization without embedding credential const provider = new StaticGitHubCredentialProvider( 'github_pat_abcdefghijklmnopqrstuvwxyz', ); + const encodedCredential = Buffer.from( + 'x-access-token:github_pat_abcdefghijklmnopqrstuvwxyz', + 'utf8', + ).toString('base64'); assert.deepEqual( gitHubCredentialEnvironment(await provider.getCredential()), { - [GITHUB_CREDENTIAL_ENV_NAME]: 'github_pat_abcdefghijklmnopqrstuvwxyz', + [GITHUB_CREDENTIAL_ENV_NAME]: encodedCredential, }, ); + assert.ok(!encodedCredential.includes('github_pat_')); }); test('composes the masked credential with SRT Git configuration inside the sandbox', () => { @@ -155,7 +160,7 @@ test('composes the masked credential with SRT Git configuration inside the sandb assert.match(wrapped, /http\.https:\/\/github\.com\/\.extraheader/); assert.match(wrapped, /\$\{LIBRECHAT_CODE_GITHUB_AUTHORIZATION\}/); assert.match(wrapped, /unset LIBRECHAT_CODE_GITHUB_AUTHORIZATION/); - assert.equal(wrapped.match(/Authorization: Bearer/g)?.length, 1); + assert.equal(wrapped.match(/Authorization: Basic/g)?.length, 1); assert.ok(!wrapped.includes('github_pat_')); }); diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index 7b1f12b1..b8046897 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -197,7 +197,10 @@ export function gitHubCredentialEnvironment( credential: GitHubCredential, ): Record { return { - [GITHUB_CREDENTIAL_ENV_NAME]: credential.value, + [GITHUB_CREDENTIAL_ENV_NAME]: Buffer.from( + `x-access-token:${credential.value}`, + 'utf8', + ).toString('base64'), }; } @@ -250,14 +253,14 @@ export function wrapGitHubCredentialCommand( return [ 'set "GIT_CONFIG_GLOBAL=NUL"', 'set "GIT_CONFIG_NOSYSTEM=1"', - `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Bearer %${GITHUB_CREDENTIAL_ENV_NAME}%'"`, + `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'"`, `set "${GITHUB_CREDENTIAL_ENV_NAME}="`, command, ].join(' && '); } return [ 'export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1', - `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Bearer \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`, + `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`, `unset ${GITHUB_CREDENTIAL_ENV_NAME}`, command, ].join(';\n'); From 1c7af888c774a52d3a8d4170590c6c409b03ae6e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 01:17:13 -0400 Subject: [PATCH 2/3] fix: isolate file deletion rate limits (#193) --- service/src/config.ts | 6 +++ service/src/middleware/limits.test.ts | 56 ++++++++++++++++++++++++ service/src/middleware/limits.ts | 11 +++++ service/src/service/exec-timeout.test.ts | 2 +- service/src/service/router.ts | 12 +++-- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/service/src/config.ts b/service/src/config.ts index 90df6b58..d025831e 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -346,6 +346,12 @@ export const env = { // Files List Rate Limits FETCH_LIMIT_WINDOW: Number(process.env.FETCH_LIMIT_WINDOW) || 60 * 1000, // 1 minute FETCH_MAX_REQUESTS: Number(process.env.FETCH_MAX_REQUESTS) || 120, // 120 requests per minute + // File Delete Rate Limits. Fall back to the fetch settings so existing + // deployments keep their current limits while using an independent bucket. + DELETE_LIMIT_WINDOW: + Number(process.env.DELETE_LIMIT_WINDOW) || Number(process.env.FETCH_LIMIT_WINDOW) || 60 * 1000, + DELETE_MAX_REQUESTS: + Number(process.env.DELETE_MAX_REQUESTS) || Number(process.env.FETCH_MAX_REQUESTS) || 120, // Redis Key Cache Config SESSION_CACHE_TTL: Number(process.env.SESSION_CACHE_TTL) || 86400, /** TTL for the durable `session-owner:` record that backs diff --git a/service/src/middleware/limits.test.ts b/service/src/middleware/limits.test.ts index e7c1c46f..12f3daf4 100644 --- a/service/src/middleware/limits.test.ts +++ b/service/src/middleware/limits.test.ts @@ -111,6 +111,40 @@ async function startRateLimitedApp(max: number, windowMs: number): Promise { + const redis = new TestRedisRateLimitStore(); + setRateLimitRedisForTests(redis); + + const app = express(); + app.use((req, _res, next) => { + applyPrincipal(req as AuthenticatedRequest, { + userId: 'user-a', + tenantId: 'tenant-a', + principalSource: 'librechat_jwt', + }); + next(); + }); + app.get( + '/v1/files/session-a', + createRateLimiter('test-fetch', windowMs, max, { message: 'Too many file list requests.' }), + (_req, res) => res.status(200).json({ ok: true }), + ); + app.delete( + '/v1/files/session-a/file-a', + createRateLimiter('test-delete', windowMs, max, { + message: 'Too many file deletion requests.', + structuredBody: true, + }), + (_req, res) => res.status(200).json({ ok: true }), + ); + + const server = app.listen(0, '127.0.0.1'); + servers.push(server); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; +} + function postExec(url: string, headers: Record = {}): Promise { return fetch(`${url}/v1/exec`, { method: 'POST', @@ -228,3 +262,25 @@ describe('execution rate limiting', () => { expect((await postExec(url)).status).toBe(200); }); }); + +describe('file operation rate limiting', () => { + test('keeps deletion traffic out of the file-list bucket and returns structured retry guidance', async () => { + const url = await startIndependentFileLimiterApp(1, 30_000); + + expect((await fetch(`${url}/v1/files/session-a`)).status).toBe(200); + expect((await fetch(`${url}/v1/files/session-a/file-a`, { method: 'DELETE' })).status).toBe(200); + + const rejectedDelete = await fetch(`${url}/v1/files/session-a/file-a`, { method: 'DELETE' }); + const body = await rejectedDelete.json() as ReturnType; + expect(rejectedDelete.status).toBe(429); + expect(rejectedDelete.headers.get('retry-after')).not.toBeNull(); + expect(body.error).toBe('rate_limited'); + expect(body.message).toContain('Too many file deletion requests.'); + + const rejectedList = await fetch(`${url}/v1/files/session-a`); + expect(rejectedList.status).toBe(429); + expect(await rejectedList.json()).toEqual({ + error: expect.stringContaining('Too many file list requests.'), + }); + }); +}); diff --git a/service/src/middleware/limits.ts b/service/src/middleware/limits.ts index b16ed196..099261a1 100644 --- a/service/src/middleware/limits.ts +++ b/service/src/middleware/limits.ts @@ -202,3 +202,14 @@ export const fetchLimiter = createRateLimiter( env.FETCH_MAX_REQUESTS, { message: 'Too many file list requests.' } ); + +export const deleteLimiter = createRateLimiter( + 'delete', + env.DELETE_LIMIT_WINDOW, + env.DELETE_MAX_REQUESTS, + { + message: 'Too many file deletion requests.', + structuredBody: true, + logRejections: true, + } +); diff --git a/service/src/service/exec-timeout.test.ts b/service/src/service/exec-timeout.test.ts index 70e00d17..b10a94c6 100644 --- a/service/src/service/exec-timeout.test.ts +++ b/service/src/service/exec-timeout.test.ts @@ -11,7 +11,7 @@ test('/exec validates timeout before enqueue and forwards its cap to both langua mock.module('./src/middleware/auth', () => ({ sessionAuth: passthrough })); mock.module('./src/middleware/limits', () => ({ executionLimiter: passthrough, uploadLimiter: passthrough, - downloadLimiter: passthrough, fetchLimiter: passthrough, + downloadLimiter: passthrough, fetchLimiter: passthrough, deleteLimiter: passthrough, })); mock.module('./src/lifecycle', () => ({ checkServiceStartUp: () => false, checkServiceShutDown: () => false, diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 2c42f60c..43d0ff4b 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -7,7 +7,13 @@ import { Readable } from 'stream'; import type * as t from '../types'; import { checkServiceStartUp, checkServiceShutDown } from '../lifecycle'; import { sessionAuth } from '../middleware/auth'; -import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from '../middleware/limits'; +import { + executionLimiter, + uploadLimiter, + downloadLimiter, + fetchLimiter, + deleteLimiter, +} from '../middleware/limits'; import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection } from '../queue'; @@ -997,7 +1003,7 @@ const deleteSessionObject = async (req: t.AuthenticatedRequest, res: Response) = } }; -router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, deleteSessionObject); +router.delete('/files/:session_id/:fileId', deleteLimiter, sessionAuth, deleteSessionObject); /** * Alias of the route above, on the path LibreChat's `deleteCodeEnvFile` @@ -1013,6 +1019,6 @@ router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, deleteSes * * GET on this same path is the metadata proxy above. */ -router.delete('/sessions/:session_id/objects/:fileId', fetchLimiter, sessionAuth, deleteSessionObject); +router.delete('/sessions/:session_id/objects/:fileId', deleteLimiter, sessionAuth, deleteSessionObject); export default router; From 737f498ebedc3b9b26da3e5f206d7e0c49b4e901 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 01:27:39 -0400 Subject: [PATCH 3/3] feat: broker GitHub CLI authentication (#194) --- packages/code/README.md | 19 +++++---- packages/code/src/cli.ts | 15 +++---- packages/code/src/github.test.ts | 73 ++++++++++++++++++++++++++++++++ packages/code/src/github.ts | 40 +++++++++++++++++ 4 files changed, 129 insertions(+), 18 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index e46d6fd6..d06fbf9a 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -180,14 +180,17 @@ verification are implemented; use macOS, Linux, or WSL2. This also applies to Gi private keys. Git receives authentication through process-scoped `GIT_CONFIG_*` variables. -The same isolated config supplies the standard Git LFS filters; hosts using LFS -must install `git-lfs`, and checkout fails instead of silently leaving pointer -files when it is unavailable. -SRT replaces only the bearer-token portion with a sentinel inside the sandbox -and substitutes the real value in its host proxy only for `github.com` HTTPS -traffic. TLS termination is enabled for that substitution. The worker restores -the parent environment immediately after constructing the sandbox command; it -never writes credentials into the repository, a remote URL, or Git config. +When the GitHub CLI is installed, `gh api`, pull-request, issue, and workflow +commands receive the same installation scope through `GH_TOKEN` (or +`GH_ENTERPRISE_TOKEN` for GHES). The same isolated Git config supplies the +standard Git LFS filters; hosts using LFS must install `git-lfs`, and checkout +fails instead of silently leaving pointer files when it is unavailable. +SRT replaces each real credential with a sentinel inside the sandbox and +substitutes the real value in its host proxy only for the corresponding Git or +GitHub API host. TLS termination is enabled for that substitution. The worker +restores the parent environment immediately after constructing the sandbox +command; it never writes credentials into the repository, a remote URL, Git +config, or the GitHub CLI credential store. GitHub's required domains are added to the command egress allowlist only when authentication is configured. The worker identity, GitHub App key path, token source variables, and mutation-quarantine record remain denied to sandboxed diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index fdd28e9f..a87f1a5a 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -38,11 +38,11 @@ import type { NativeProcessSandboxOptions } from './native-process.js'; import type { LocalWorkspaceConfig } from './workspace.js'; import { GITHUB_ALLOWED_DOMAINS, - GITHUB_CREDENTIAL_ENV_NAME, GitHubAppCredentialProvider, + gitHubCommandCredentialEnvironment, + gitHubMaskedCredentialVariables, StaticGitHubCredentialProvider, gitHubAuthenticationPolicyIdentity, - gitHubCredentialEnvironment, normalizeGitHubHost, wrapGitHubCredentialCommand, } from './github.js'; @@ -783,16 +783,11 @@ async function run( ...(github.provider ? { maskedEnvironment: { - variables: [ - { - name: GITHUB_CREDENTIAL_ENV_NAME, - extract: '^(.+)$', - injectHosts: [github.host], - }, - ], + variables: gitHubMaskedCredentialVariables(github.host), async resolve(signal?: AbortSignal) { - return gitHubCredentialEnvironment( + return gitHubCommandCredentialEnvironment( await github.provider!.getCredential(signal), + github.host, ); }, wrapCommand(command: string, platform: NodeJS.Platform) { diff --git a/packages/code/src/github.test.ts b/packages/code/src/github.test.ts index 78d9751d..4b028239 100644 --- a/packages/code/src/github.test.ts +++ b/packages/code/src/github.test.ts @@ -17,6 +17,9 @@ import { GitHubAppCredentialProvider, StaticGitHubCredentialProvider, gitHubAuthenticationPolicyIdentity, + gitHubCliTokenEnvironmentName, + gitHubCommandCredentialEnvironment, + gitHubMaskedCredentialVariables, GITHUB_CREDENTIAL_ENV_NAME, gitHubCredentialEnvironment, normalizeGitHubHost, @@ -150,6 +153,65 @@ test('builds process-scoped Git HTTPS authorization without embedding credential assert.ok(!encodedCredential.includes('github_pat_')); }); +test('adds a GitHub CLI token only to the command-sandbox credential bundle', async () => { + const provider = new StaticGitHubCredentialProvider( + 'github_pat_abcdefghijklmnopqrstuvwxyz', + ); + const credential = await provider.getCredential(); + assert.deepEqual(gitHubCommandCredentialEnvironment(credential), { + [GITHUB_CREDENTIAL_ENV_NAME]: Buffer.from( + 'x-access-token:github_pat_abcdefghijklmnopqrstuvwxyz', + 'utf8', + ).toString('base64'), + GH_TOKEN: 'github_pat_abcdefghijklmnopqrstuvwxyz', + }); + assert.deepEqual( + gitHubCommandCredentialEnvironment(credential, 'github.example.test'), + { + [GITHUB_CREDENTIAL_ENV_NAME]: Buffer.from( + 'x-access-token:github_pat_abcdefghijklmnopqrstuvwxyz', + 'utf8', + ).toString('base64'), + GH_ENTERPRISE_TOKEN: 'github_pat_abcdefghijklmnopqrstuvwxyz', + }, + ); +}); + +test('selects the GitHub CLI token variable for public and enterprise hosts', () => { + assert.equal(gitHubCliTokenEnvironmentName('github.com'), 'GH_TOKEN'); + assert.equal( + gitHubCliTokenEnvironmentName('github.example.test'), + 'GH_ENTERPRISE_TOKEN', + ); +}); + +test('restricts Git and GitHub CLI credential substitution to their respective hosts', () => { + assert.deepEqual(gitHubMaskedCredentialVariables('github.com'), [ + { + name: GITHUB_CREDENTIAL_ENV_NAME, + extract: '^(.+)$', + injectHosts: ['github.com'], + }, + { + name: 'GH_TOKEN', + extract: '^(.+)$', + injectHosts: ['api.github.com'], + }, + ]); + assert.deepEqual(gitHubMaskedCredentialVariables('github.example.test'), [ + { + name: GITHUB_CREDENTIAL_ENV_NAME, + extract: '^(.+)$', + injectHosts: ['github.example.test'], + }, + { + name: 'GH_ENTERPRISE_TOKEN', + extract: '^(.+)$', + injectHosts: ['github.example.test'], + }, + ]); +}); + test('composes the masked credential with SRT Git configuration inside the sandbox', () => { const wrapped = wrapGitHubCredentialCommand( 'git push', @@ -160,10 +222,21 @@ test('composes the masked credential with SRT Git configuration inside the sandb assert.match(wrapped, /http\.https:\/\/github\.com\/\.extraheader/); assert.match(wrapped, /\$\{LIBRECHAT_CODE_GITHUB_AUTHORIZATION\}/); assert.match(wrapped, /unset LIBRECHAT_CODE_GITHUB_AUTHORIZATION/); + assert.doesNotMatch(wrapped, /unset GH_TOKEN/); assert.equal(wrapped.match(/Authorization: Basic/g)?.length, 1); assert.ok(!wrapped.includes('github_pat_')); }); +test('targets GitHub CLI at an enterprise host without exposing its token', () => { + const wrapped = wrapGitHubCredentialCommand( + 'gh pr create', + 'github.example.test', + 'linux', + ); + assert.match(wrapped, /GH_HOST=github\.example\.test/); + assert.doesNotMatch(wrapped, /GH_ENTERPRISE_TOKEN=/); +}); + test('rejects an insecure GitHub App API endpoint before reading the private key', () => { assert.throws( () => diff --git a/packages/code/src/github.ts b/packages/code/src/github.ts index b8046897..c727e70d 100644 --- a/packages/code/src/github.ts +++ b/packages/code/src/github.ts @@ -204,6 +204,43 @@ export function gitHubCredentialEnvironment( }; } +export function gitHubCommandCredentialEnvironment( + credential: GitHubCredential, + host = 'github.com', +): Record { + return { + ...gitHubCredentialEnvironment(credential), + [gitHubCliTokenEnvironmentName(host)]: credential.value, + }; +} + +export function gitHubCliTokenEnvironmentName(host: string): string { + return host === 'github.com' ? 'GH_TOKEN' : 'GH_ENTERPRISE_TOKEN'; +} + +export function gitHubApiHost(host: string): string { + return host === 'github.com' ? 'api.github.com' : host; +} + +export function gitHubMaskedCredentialVariables(host: string): Array<{ + name: string; + injectHosts: string[]; + extract: string; +}> { + return [ + { + name: GITHUB_CREDENTIAL_ENV_NAME, + extract: '^(.+)$', + injectHosts: [host], + }, + { + name: gitHubCliTokenEnvironmentName(host), + extract: '^(.+)$', + injectHosts: [gitHubApiHost(host)], + }, + ]; +} + export function gitHubAuthenticationPolicyIdentity(options: { mode?: 'app' | 'token'; host: string; @@ -249,10 +286,12 @@ export function wrapGitHubCredentialCommand( platform: NodeJS.Platform = process.platform, ): string { const key = `http.https://${host}/.extraheader`; + const cliHost = host === 'github.com' ? undefined : host; if (platform === 'win32') { return [ 'set "GIT_CONFIG_GLOBAL=NUL"', 'set "GIT_CONFIG_NOSYSTEM=1"', + ...(cliHost ? [`set "GH_HOST=${cliHost}"`] : []), `set "GIT_CONFIG_PARAMETERS='http.proxyAuthMethod=basic' '${key}=Authorization: Basic %${GITHUB_CREDENTIAL_ENV_NAME}%'"`, `set "${GITHUB_CREDENTIAL_ENV_NAME}="`, command, @@ -260,6 +299,7 @@ export function wrapGitHubCredentialCommand( } return [ 'export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1', + ...(cliHost ? [`export GH_HOST=${cliHost}`] : []), `export GIT_CONFIG_PARAMETERS="'http.proxyAuthMethod=basic' '${key}=Authorization: Basic \${${GITHUB_CREDENTIAL_ENV_NAME}}'"`, `unset ${GITHUB_CREDENTIAL_ENV_NAME}`, command,