From 8f47eef1b79d7e2464d644c07e9d6af5d1fafe63 Mon Sep 17 00:00:00 2001 From: jarek Date: Sun, 9 Aug 2026 10:31:27 +0200 Subject: [PATCH] GitLab and Harbor registries no longer report a misleading auth error when browsing --- src/lib/server/registry-catalog.ts | 40 ++++++++++++++++++++++ src/routes/api/registry/catalog/+server.ts | 16 +++++++-- src/routes/api/registry/search/+server.ts | 26 ++++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/registry-catalog.ts diff --git a/src/lib/server/registry-catalog.ts b/src/lib/server/registry-catalog.ts new file mode 100644 index 0000000..426929c --- /dev/null +++ b/src/lib/server/registry-catalog.ts @@ -0,0 +1,40 @@ +/** + * Pure catalog-failure classification (#873). No docker/db imports, so it's + * unit-testable without pulling better-sqlite3 into the test process. + * + * GitLab, Harbor, and other registries refuse /v2/_catalog to non-admins by returning + * 401 with `error="insufficient_scope"` in WWW-Authenticate - the token is VALID, the + * operation just isn't permitted. That is NOT an auth failure. A real bad/expired token + * instead returns `error="invalid_token"`. Verified empirically on gitlab.com and a + * self-hosted GitLab (admin gets catalog 200; non-admin and gitlab.com get + * insufficient_scope; a bad token gets invalid_token). + */ + +/** + * @param status HTTP status of the catalog response + * @param wwwAuthenticate the response's WWW-Authenticate header (may be null) + * @param authState 'authed' (we obtained and sent a bearer token), 'rejected' (registry + * HAS stored credentials but the token exchange failed - bad/expired creds), or 'anon' + * (no credentials configured) + * @returns 'not_supported' (registry won't list - point user to search) or 'auth_failed' + */ +export function classifyCatalogFailure( + status: number, + wwwAuthenticate: string | null, + authState: 'authed' | 'rejected' | 'anon' +): 'not_supported' | 'auth_failed' { + const err = (wwwAuthenticate || '').match(/error="([^"]+)"/i)?.[1]?.toLowerCase(); + // An explicit error code is authoritative regardless of how we authenticated. + if (err === 'insufficient_scope') return 'not_supported'; + if (err === 'invalid_token') return 'auth_failed'; + // Credentials were configured but the token exchange failed -> real auth problem. + if (authState === 'rejected') return 'auth_failed'; + // We authenticated fine but still can't list -> the token lacks catalog permission + // (GitLab/Harbor restrict it to admins). Anonymous 401 is the normal login challenge. + if ((status === 401 || status === 403) && authState === 'authed') return 'not_supported'; + return 'auth_failed'; +} + +/** User-facing message for a registry that won't allow catalog listing (#873). */ +export const CATALOG_NOT_SUPPORTED_MSG = + 'This registry does not allow listing all images (GitLab and Harbor restrict it to admins). Search for an image by its full path instead.'; diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index 4bf762b..85e700c 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; -import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl } from '$lib/server/docker'; +import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistryUrl, classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from '$lib/server/docker'; const PAGE_SIZE = 100; @@ -53,7 +53,19 @@ export const GET: RequestHandler = async ({ url }) => { if (!response.ok) { if (response.status === 401 || response.status === 403) { - return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status }); + // GitLab/Harbor refuse catalog to non-admins with a VALID token (#873): + // distinguish "listing not permitted" from a real auth failure. + const hasCreds = !!(registry.username && registry.password); + const authState = hasCreds ? (authHeader ? 'authed' : 'rejected') : 'anon'; + const kind = classifyCatalogFailure( + response.status, + response.headers.get('WWW-Authenticate'), + authState + ); + if (kind === 'not_supported') { + return json({ error: CATALOG_NOT_SUPPORTED_MSG, notSupported: true }, { status: response.status }); + } + return json({ error: 'Authentication failed. Check the registry credentials.' }, { status: response.status }); } if (response.status === 404) { return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 }); diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index b834af4..dc2d238 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -1,7 +1,10 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; -import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl } from '$lib/server/docker'; +import { getRegistryAuth, isHarborRegistry, harborSearchRepositories, parseRegistryUrl, classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from '$lib/server/docker'; + +/** Thrown when the registry refuses catalog listing to a valid token (GitLab/Harbor, #873). */ +class CatalogNotSupportedError extends Error {} interface SearchResult { name: string; @@ -165,7 +168,20 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis if (!response.ok) { if (response.status === 401 || response.status === 403) { - throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).'); + // Same GitLab/Harbor catalog case as Browse (#873): a valid token that + // isn't allowed to list is NOT an auth failure. Throw a marked error so + // searchPrivateRegistry can surface the right message. + const hasCreds = !!(registry.username && registry.password); + const authState = hasCreds ? (authHeader ? 'authed' : 'rejected') : 'anon'; + const kind = classifyCatalogFailure( + response.status, + response.headers.get('WWW-Authenticate'), + authState + ); + if (kind === 'not_supported') { + throw new CatalogNotSupportedError(CATALOG_NOT_SUPPORTED_MSG); + } + throw new Error('Authentication failed. Check the registry credentials.'); } throw new Error(`Registry returned error: ${response.status}`); } @@ -234,6 +250,12 @@ export const GET: RequestHandler = async ({ url }) => { return json(results); } catch (error: any) { + // Registry won't allow catalog listing (GitLab/Harbor, #873) - not an error the + // user can fix. Return an empty result with a hint, not a 500 auth failure. + if (error instanceof CatalogNotSupportedError) { + return json({ results: [], notSupported: true, message: CATALOG_NOT_SUPPORTED_MSG }); + } + console.error('Failed to search images:', error); if (error.code === 'ECONNREFUSED') {