diff --git a/src/cliproxy/auth/gitlab-pat-response.ts b/src/cliproxy/auth/gitlab-pat-response.ts new file mode 100644 index 00000000..50a73b8b --- /dev/null +++ b/src/cliproxy/auth/gitlab-pat-response.ts @@ -0,0 +1,90 @@ +const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500; +const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; +const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]'; + +function sanitizeGitLabPatErrorDetail( + detail: string | undefined, + submittedToken?: string +): string | undefined { + const trimmed = detail?.trim(); + if (!trimmed) { + return undefined; + } + + if (/^]+>/.test(trimmed)) { + return HTML_ERROR_RESPONSE_OMITTED; + } + + let sanitized = trimmed.replace( + /"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi, + '"$1":"[redacted]"' + ); + + if (submittedToken) { + sanitized = sanitized.split(submittedToken).join('[redacted]'); + } + + sanitized = sanitized + .replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]') + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + .replace(/\s+/g, ' '); + + if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice( + 0, + GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`; + } + + return sanitized; +} + +export function parseGitLabPatAuthResponse( + responseOk: boolean, + responseStatus: number, + responseBody: string, + submittedToken?: string +): + | { ok: true; payload: Record } + | { ok: false; payload: Record; errorMessage: string } { + const trimmedBody = responseBody.trim(); + let payload: Record = {}; + + if (trimmedBody) { + try { + payload = JSON.parse(trimmedBody) as Record; + } catch { + payload = { + error: + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`, + }; + } + } + + const payloadError = + typeof payload.error === 'string' + ? sanitizeGitLabPatErrorDetail(payload.error, submittedToken) + : undefined; + const fallbackError = + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`; + + if (!responseOk) { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + if (payload.status !== 'ok') { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + return { ok: true, payload }; +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index ea2381e0..b6ba5e7e 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -56,6 +56,7 @@ import { } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; +import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; import { getProxyTarget, buildProxyUrl, @@ -723,22 +724,16 @@ async function handleGitLabPatLogin( }), }); - const responseBody = (await response.text()).trim(); - let payload: Record = {}; - if (responseBody) { - try { - payload = JSON.parse(responseBody) as Record; - } catch { - payload = { error: responseBody }; - } - } + const responseBody = await response.text(); + const parsedResponse = parseGitLabPatAuthResponse( + response.ok, + response.status, + responseBody, + token + ); - if (!response.ok || payload.status !== 'ok') { - const errorMessage = - (typeof payload.error === 'string' && payload.error) || - responseBody || - `GitLab PAT login failed with status ${response.status}`; - console.log(fail(errorMessage)); + if (!parsedResponse.ok) { + console.log(fail(parsedResponse.errorMessage)); return null; } diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 8e63e3bc..6e12f5fa 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -43,6 +43,7 @@ import { listProviderTokenSnapshots, registerAccountFromToken, } from '../../cliproxy/auth/token-manager'; +import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -706,13 +707,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise ({}))) as { - status?: string; - error?: string; - }; - if (!response.ok || data.status !== 'ok') { + const responseBody = await response.text(); + const parsedResponse = parseGitLabPatAuthResponse( + response.ok, + response.status, + responseBody, + gitlabPersonalAccessToken + ); + if (!parsedResponse.ok) { res.status(response.ok ? 400 : response.status).json({ - error: data.error || 'GitLab PAT authentication failed', + error: parsedResponse.errorMessage || 'GitLab PAT authentication failed', }); return; } diff --git a/tests/unit/cliproxy/gitlab-pat-response.test.ts b/tests/unit/cliproxy/gitlab-pat-response.test.ts new file mode 100644 index 00000000..029a276b --- /dev/null +++ b/tests/unit/cliproxy/gitlab-pat-response.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response'; + +describe('parseGitLabPatAuthResponse', () => { + it('sanitizes HTML error bodies for non-ok responses', () => { + const result = parseGitLabPatAuthResponse( + false, + 502, + 'gateway error', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toBe('[HTML error response omitted]'); + } + }); + + it('sanitizes reflected PAT tokens from structured error payloads', () => { + const result = parseGitLabPatAuthResponse( + false, + 400, + JSON.stringify({ status: 'error', error: 'Rejected token glpat-secret-token for login' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('[redacted]'); + expect(result.errorMessage).not.toContain('glpat-secret-token'); + } + }); + + it('rejects ok responses whose body is not valid success JSON', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + 'upstream temporarily unavailable', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('upstream temporarily unavailable'); + } + }); + + it('accepts explicit success payloads', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + JSON.stringify({ status: 'ok', saved_path: '/tmp/gitlab.json' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.payload.status).toBe('ok'); + } + }); +});