GitLab and Harbor registries no longer report a misleading auth error when browsing

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent b287cee31f
commit 8f47eef1b7
3 files changed
+78 -4

No files matched your search

+40
View File
@@ -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.';
+14 -2
View File
@@ -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 });
+24 -2
View File
@@ -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') {