Improve Docker redirect and auth header handling

Adds special handling for Docker registry redirects to prevent leaking Authorization headers to S3 or blob storage by using manual redirect mode and stripping sensitive headers before following redirects. Refactors scope handling for unauthorized responses and ensures retry logic for token fetches also respects Docker redirect requirements.
This commit is contained in:
xixu-me committed 2025-12-12 13:29:07 +08:00
1 parent 296b935cdf
commit 2e7716d66c
2 files changed
+55 -27

No files matched your search

+52 -20
View File
@@ -265,6 +265,11 @@ async function handleRequest(request, env, ctx) {
signal: controller.signal
};
// Special handling for Docker redirects to avoid leaking Auth headers to S3 (blobs)
if (isDocker) {
finalFetchOptions.redirect = 'manual';
}
// Special handling for HEAD requests to ensure Content-Length header
if (request.method === 'HEAD') {
response = await fetch(targetUrl, finalFetchOptions);
@@ -327,6 +332,24 @@ async function handleRequest(request, env, ctx) {
clearTimeout(timeoutId);
// Handle manual redirect for Docker
if (isDocker && (response.status === 301 || response.status === 302 || response.status === 307)) {
const location = response.headers.get('Location');
if (location) {
// Fetch the new location without Authorization header
// Cloudflare Workers fetch should follow this automatically if we used 'follow',
// but we used 'manual' to strip headers.
const redirectHeaders = new Headers(finalFetchOptions.headers);
redirectHeaders.delete('Authorization');
response = await fetch(location, {
...finalFetchOptions,
headers: redirectHeaders,
redirect: 'follow' // Follow subsequent redirects normally
});
}
}
if (response.ok || response.status === 206) {
monitor.mark('success');
break;
@@ -337,22 +360,9 @@ async function handleRequest(request, env, ctx) {
monitor.mark('docker_auth_challenge');
const authenticateStr = response.headers.get('WWW-Authenticate');
let scope = '';
// Calculate scope first so we can use it for both token fetch and unauthorized response
scope = getScopeFromUrl(url, effectivePath, platform);
// Construct client scope (with platform prefix) for the unauthorized response
// This ensures the client asks for a token with a scope that Xget can route
let clientScope = scope;
if (platform.startsWith('cr-') && scope.startsWith('repository:')) {
const parts = scope.split(':');
if (parts.length >= 3) {
const repoName = parts[1];
const prefix = platform.replace(/-/g, '/');
clientScope = `repository:${prefix}/${repoName}:${parts.slice(2).join(':')}`;
}
}
// Calculate scope for upstream token fetch
let scope = getScopeFromUrl(url, effectivePath, platform);
if (authenticateStr) {
try {
@@ -370,11 +380,33 @@ async function handleRequest(request, env, ctx) {
if (tokenData.token) {
const retryHeaders = new Headers(requestHeaders);
retryHeaders.set('Authorization', `Bearer ${tokenData.token}`);
const retryResponse = await fetch(targetUrl, {
const retryOptions = {
...finalFetchOptions,
headers: retryHeaders
});
};
// Also use manual redirect for retry
if (isDocker) {
retryOptions.redirect = 'manual';
}
let retryResponse = await fetch(targetUrl, retryOptions);
// Handle manual redirect for retry
if (isDocker && (retryResponse.status === 301 || retryResponse.status === 302 || retryResponse.status === 307)) {
const location = retryResponse.headers.get('Location');
if (location) {
const redirectHeaders = new Headers(retryOptions.headers);
redirectHeaders.delete('Authorization');
retryResponse = await fetch(location, {
...retryOptions,
headers: redirectHeaders,
redirect: 'follow'
});
}
}
if (retryResponse.ok) {
response = retryResponse;
@@ -388,7 +420,7 @@ async function handleRequest(request, env, ctx) {
}
}
response = responseUnauthorized(url, clientScope);
response = responseUnauthorized(url);
break;
}
+3 -7
View File
@@ -131,16 +131,12 @@ export function getScopeFromUrl(url, effectivePath, platform) {
* Generates a Docker/OCI registry-compliant 401 response with a WWW-Authenticate
* header that directs clients to the token authentication endpoint.
* @param {URL} url - Request URL used to construct authentication realm
* @param {string} [scope] - Optional scope to include in the challenge
* @param {URL} url - Request URL used to construct authentication realm
* @returns {Response} Unauthorized response with WWW-Authenticate header
*/
export function responseUnauthorized(url, scope) {
export function responseUnauthorized(url) {
const headers = new Headers();
let authHeader = `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`;
if (scope) {
authHeader += `,scope="${scope}"`;
}
headers.set('WWW-Authenticate', authHeader);
headers.set('WWW-Authenticate', `Bearer realm="https://${url.hostname}/v2/auth",service="Xget"`);
return new Response(JSON.stringify({
errors: [{
code: 'UNAUTHORIZED',