mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(gitlab): sanitize PAT auth failures across cli and web
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user