fix(gitlab): sanitize PAT auth failures across cli and web

This commit is contained in:
Tam Nhu Tran
2026-04-15 02:01:39 -04:00
parent 4e738ee021
commit 7f1c23607d
4 changed files with 171 additions and 21 deletions
+90
View File
@@ -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 (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.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<string, unknown> }
| { ok: false; payload: Record<string, unknown>; errorMessage: string } {
const trimmedBody = responseBody.trim();
let payload: Record<string, unknown> = {};
if (trimmedBody) {
try {
payload = JSON.parse(trimmedBody) as Record<string, unknown>;
} 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 };
}
+10 -15
View File
@@ -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<string, unknown> = {};
if (responseBody) {
try {
payload = JSON.parse(responseBody) as Record<string, unknown>;
} 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;
}
+10 -6
View File
@@ -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<voi
}),
});
const data = (await response.json().catch(() => ({}))) 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;
}
@@ -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,
'<html><body>gateway error</body></html>',
'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');
}
});
});