fix: request registry tokens with the target repository scope (#1478)

* fix: request registry tokens with the target repository scope

The image-update detector authenticated to registries by reusing the scope
echoed in the registry's GET /v2/ ping. That ping carries no repository
context, and ghcr.io answers it with a placeholder scope
(repository:user/image:pull), so the token was requested for the wrong
repository and rejected. Every ghcr.io-backed image (including lscr.io, which
delegates auth to ghcr.io) then failed its manifest lookup and was reported as
"Registry unreachable", while Docker Hub and quay.io kept working. Always
request a pull scope for the repository being checked rather than the echoed
placeholder.

Also report the actual failure cause: getRemoteDigestResult now distinguishes
an authentication failure, a rate limit (with retry-after), a missing image, a
registry error, and a genuinely unreachable registry, instead of collapsing
every failure into "Registry unreachable". getRemoteDigest stays a
digest-or-null wrapper so the update-preview path is unchanged, and
listRegistryTags shares the same token path so it now resolves on
ghcr.io/lscr.io too.

* fix: neutralize control characters in the registry digest error log

The error-path console.error in getRemoteDigestResult interpolated the image
ref and the caught error message, both of which originate from compose-authored
input. Route them through sanitizeForLog so a crafted image string or upstream
error text cannot forge multi-line log entries (log injection). The returned
reason and the digest logic are unchanged.
This commit is contained in:
Anso
2026-06-26 21:06:40 -04:00
committed by GitHub
parent 628400ac19
commit 2911ccfe2b
5 changed files with 231 additions and 28 deletions
@@ -94,6 +94,14 @@ vi.mock('../services/NodeRegistry', () => ({
},
}));
// getRemoteDigestResult is module-scoped inside checkImage; mock it to drive the remote
// outcome while keeping the real parseImageRef / repoDigestMatchesRef.
const { mockGetRemoteDigestResult } = vi.hoisted(() => ({ mockGetRemoteDigestResult: vi.fn() }));
vi.mock('../services/registry-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/registry-api')>();
return { ...actual, getRemoteDigestResult: mockGetRemoteDigestResult };
});
// ── Re-export internal helpers via the module ─────────────────────────
// We need the internal functions. Import the module after mocks are set up.
@@ -186,6 +194,38 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
});
});
// ── checkImage surfaces the remote-digest reason ───────────────────────
describe('ImageUpdateService - checkImage surfaces the remote-digest reason', () => {
let service: ImageUpdateService;
beforeEach(() => {
vi.clearAllMocks();
(ImageUpdateService as any).instance = undefined;
service = ImageUpdateService.getInstance();
});
// One RepoDigest matching the ref so the local digest resolves and the flow reaches
// getRemoteDigestResult.
const dockerWithLocalDigest = (digest: string) => ({
getDocker: () => ({
getImage: () => ({ inspect: vi.fn().mockResolvedValue({ RepoDigests: [`ghcr.io/linuxserver/radarr@${digest}`] }) }),
}),
} as any);
it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => {
mockGetRemoteDigestResult.mockResolvedValue({ ok: false, reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
const result = await service.checkImage(dockerWithLocalDigest('sha256:local'), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
});
it('reports an update when the resolved remote digest differs from the local one', async () => {
mockGetRemoteDigestResult.mockResolvedValue({ ok: true, digest: 'sha256:remote' });
const result = await service.checkImage(dockerWithLocalDigest('sha256:local'), 'ghcr.io/linuxserver/radarr:latest');
expect(result).toEqual({ hasUpdate: true });
});
});
// ── Rate limiting ─────────────────────────────────────────────────────
describe('ImageUpdateService - manual refresh cooldown', () => {