172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { encodeRegistryAuth } from '../src/lib/server/registry-auth';
|
|
|
|
describe('encodeRegistryAuth', () => {
|
|
test('produces base64url WITH = padding (Go URLEncoding compatible)', () => {
|
|
// Docker daemon uses base64.URLEncoding.DecodeString which requires padding.
|
|
// Unpadded base64url is silently treated as malformed → anonymous pull → rate limit.
|
|
// Reference: moby/api/pkg/authconfig/authconfig.go
|
|
// Fake 36-char value matching the shape of a Docker Hub PAT
|
|
// (the test only cares about encoding shape, not validity).
|
|
const header = encodeRegistryAuth({
|
|
username: 'someuser',
|
|
password: 'x'.repeat(36),
|
|
serveraddress: 'https://index.docker.io/v1/'
|
|
});
|
|
|
|
// Length must be a multiple of 4 (padding requirement)
|
|
expect(header.length % 4).toBe(0);
|
|
|
|
// Must end in '=' for this particular payload (verified manually)
|
|
expect(header.endsWith('=')).toBe(true);
|
|
|
|
// Must use base64url alphabet (no '+' or '/')
|
|
expect(header).not.toMatch(/[+/]/);
|
|
});
|
|
|
|
test('round-trips through Node Buffer base64url decode', () => {
|
|
const input = { username: 'u', password: 'p', serveraddress: 'r' };
|
|
const header = encodeRegistryAuth(input);
|
|
// Node's base64url decode tolerates padding, so we can verify round-trip
|
|
const decoded = JSON.parse(Buffer.from(header, 'base64url').toString());
|
|
expect(decoded).toEqual(input);
|
|
});
|
|
|
|
test('empty payload encodes to "e30=" (base64url of "{}")', () => {
|
|
const header = encodeRegistryAuth({});
|
|
expect(header).toBe('e30=');
|
|
});
|
|
|
|
test('payload length divisible by 3 produces no padding', () => {
|
|
// "abc" base64url-encodes to "YWJj" (4 chars, no padding needed)
|
|
const header = encodeRegistryAuth({ x: 'a' }); // {"x":"a"} = 9 bytes
|
|
// 9 bytes → 12 base64 chars exactly, no padding
|
|
expect(header).toBe('eyJ4IjoiYSJ9');
|
|
expect(header.length % 4).toBe(0);
|
|
});
|
|
});
|
|
|
|
import { canReattachAuthOnRedirect } from '../src/lib/server/registry-auth';
|
|
|
|
describe('canReattachAuthOnRedirect', () => {
|
|
test('same host, scheme change only -> re-attach (the Harbor http->https realm case, #1428)', () => {
|
|
expect(canReattachAuthOnRedirect(
|
|
'http://docker.example.com/service/token?service=harbor-registry',
|
|
'https://docker.example.com/service/token?service=harbor-registry'
|
|
)).toBe(true);
|
|
});
|
|
|
|
test('same host, different port -> re-attach', () => {
|
|
expect(canReattachAuthOnRedirect('https://reg.example.com/token', 'https://reg.example.com:8443/token')).toBe(true);
|
|
});
|
|
|
|
test('different host -> refuse (never leak creds to another server)', () => {
|
|
expect(canReattachAuthOnRedirect('https://reg.example.com/token', 'https://evil.example.com/token')).toBe(false);
|
|
});
|
|
|
|
test('different subdomain counts as a different host -> refuse', () => {
|
|
expect(canReattachAuthOnRedirect('https://a.example.com/token', 'https://b.example.com/token')).toBe(false);
|
|
});
|
|
|
|
test('malformed URL -> refuse (fail closed)', () => {
|
|
expect(canReattachAuthOnRedirect('not a url', 'https://reg.example.com/token')).toBe(false);
|
|
});
|
|
});
|
|
|
|
import { fetchRegistryToken, type FetchLike } from '../src/lib/server/registry-auth';
|
|
|
|
// A tiny fake Response for the loop tests.
|
|
function fakeRes(status: number, location?: string, body = '') {
|
|
return {
|
|
status,
|
|
headers: { get: (k: string) => (k.toLowerCase() === 'location' ? location ?? null : null) },
|
|
body: null,
|
|
text: async () => body,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('fetchRegistryToken redirect follow', () => {
|
|
test('no redirect: returns the first response, auth sent once', async () => {
|
|
const seen: Array<{ url: string; auth?: string }> = [];
|
|
const fake: FetchLike = async (url, init) => {
|
|
seen.push({ url, auth: init?.headers?.['Authorization'] });
|
|
return fakeRes(200, undefined, '{"token":"ok"}');
|
|
};
|
|
const res = await fetchRegistryToken('https://reg.example.com/token', 'Basic abc', fake);
|
|
expect(res.status).toBe(200);
|
|
expect(seen.length).toBe(1);
|
|
expect(seen[0].auth).toBe('Basic abc');
|
|
});
|
|
|
|
test('same-host http->https redirect: follows and re-attaches auth (#1428)', async () => {
|
|
const seen: Array<{ url: string; auth?: string }> = [];
|
|
const fake: FetchLike = async (url, init) => {
|
|
seen.push({ url, auth: init?.headers?.['Authorization'] });
|
|
if (url.startsWith('http://')) return fakeRes(301, 'https://reg.example.com/token?x=1');
|
|
return fakeRes(200, undefined, '{"token":"ok"}');
|
|
};
|
|
const res = await fetchRegistryToken('http://reg.example.com/token?x=1', 'Basic abc', fake);
|
|
expect(res.status).toBe(200);
|
|
expect(seen.length).toBe(2);
|
|
expect(seen[0].auth).toBe('Basic abc'); // sent on the http realm
|
|
expect(seen[1].auth).toBe('Basic abc'); // re-attached after the same-host 301
|
|
});
|
|
|
|
test('cross-host redirect: follows but DROPS auth (no credential leak)', async () => {
|
|
const seen: Array<{ url: string; auth?: string }> = [];
|
|
const fake: FetchLike = async (url, init) => {
|
|
seen.push({ url, auth: init?.headers?.['Authorization'] });
|
|
if (url.includes('reg.example.com')) return fakeRes(302, 'https://evil.example.com/token');
|
|
return fakeRes(200, undefined, '{"token":"anon"}');
|
|
};
|
|
const res = await fetchRegistryToken('https://reg.example.com/token', 'Basic secret', fake);
|
|
expect(res.status).toBe(200);
|
|
expect(seen[0].auth).toBe('Basic secret');
|
|
expect(seen[1].auth).toBeUndefined(); // stripped crossing to evil.example.com
|
|
});
|
|
|
|
test('redirect loop is bounded (does not hang)', async () => {
|
|
let calls = 0;
|
|
const fake: FetchLike = async (url) => { calls++; return fakeRes(301, 'https://reg.example.com/again'); };
|
|
await fetchRegistryToken('https://reg.example.com/token', null, fake);
|
|
expect(calls).toBeLessThanOrEqual(5); // 4 hops + 1 final
|
|
});
|
|
|
|
test('a blocked initial realm host throws before any fetch', async () => {
|
|
let calls = 0;
|
|
const fake: FetchLike = async () => { calls++; return fakeRes(200, undefined, '{"token":"x"}'); };
|
|
await expect(fetchRegistryToken('http://169.254.169.254/token', null, fake)).rejects.toThrow();
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
test('a redirect to a blocked host throws instead of following it', async () => {
|
|
let calls = 0;
|
|
const fake: FetchLike = async () => { calls++; return fakeRes(302, 'http://127.0.0.1/token'); };
|
|
await expect(fetchRegistryToken('https://reg.example.com/token', null, fake)).rejects.toThrow();
|
|
expect(calls).toBe(1); // fetched the public host once, refused the private redirect
|
|
});
|
|
|
|
test('a public realm (Docker Hub) is allowed', async () => {
|
|
const fake: FetchLike = async () => fakeRes(200, undefined, '{"token":"x"}');
|
|
const res = await fetchRegistryToken('https://auth.docker.io/token?service=registry.docker.io', 'Basic x', fake);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
import { isSafeRegistryHost } from '../src/lib/server/registry-auth';
|
|
|
|
describe('isSafeRegistryHost', () => {
|
|
// Image-derived registry hosts: block the dangerous set, allow public + LAN.
|
|
test('blocks loopback, cloud metadata and reserved', () => {
|
|
for (const h of ['127.0.0.1', '127.0.0.1:5000', 'localhost', '169.254.169.254', '169.254.169.254:80', '0.0.0.0', '[::1]']) {
|
|
expect(isSafeRegistryHost(h).ok).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('allows public registries and LAN self-hosted registries', () => {
|
|
for (const h of ['ghcr.io', 'registry-1.docker.io', 'registry.example.com', '192.168.1.50:5000', '10.0.0.5:5000', '172.16.4.4']) {
|
|
expect(isSafeRegistryHost(h).ok).toBe(true);
|
|
}
|
|
});
|
|
});
|