Files
Jamie Tanna 29a7b9c5f5 fix(util/http): allow blocking requests to internal hosts
To harden Renovate from being able to access internal hosts, we can add
a level of enforcement on all outgoing requests.

We can split this into two types of traffic:

- where the response becomes configuration (i.e. `extends`)
- where the response does not become configuration (i.e.
  `customDatasources`)

To harden this, we enforce on every outbound HTTP request:

- check the resolved URL before anything, including cache lookups, can
  proceed
  - catching IP-literal hosts in every spelling the WHATWG URL parser normalizes
  - blocking known Cloud metadata IPs, too
- a `dnsLookup` wrapper validates every address a hostname resolves to,
  on every new connection, which also defeats DNS rebinding
- a `beforeRedirect` hook re-runs the URL check on each redirect
  target, with the new host getting its own grant lookup rather than
  inheriting the original host's verdict

We default this enforcement to `warn`, to not break existing deployments
and instead give them visibility into required configuration before a
future major version (major+1) enables `block` by default.

We'll also remove allow in a future major version (major+2), to give
time for admins to migrate over.

When a request is blocked, this degrades like an unreachable registry,
so a Renovate run isn't aborted by a blocked request.

As part of this, we can also provide a worked example of the different
cases that do/do not need allowing via `hostRules`.

Requests through an admin-configured proxy resolve DNS at the proxy, so
`dnsLookup` never runs for them, so we can resolve the hostname locally
and validate every address before the request instead.

This doesn't catch everything, especially as the proxy may resolve the
name to a different result, and if we can't resolve the name - possible
in locked-down corporate environments - we fail open.

Child processes aren't currently able to route traffic through
Renovate's HTTP layer, so are unrestricted.

Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
2026-09-10 14:52:01 +01:00

402 lines
14 KiB
TypeScript

import { isNonEmptyString, isString, isUndefined } from '@sindresorhus/is';
import {
HOST_BLOCKED,
HOST_DISABLED,
PAGE_NOT_FOUND_ERROR,
} from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
import { coerceArray } from '../../../util/array.ts';
import { detectPlatform } from '../../../util/common.ts';
import { parseGitUrl } from '../../../util/git/url.ts';
import { toSha256 } from '../../../util/hash.ts';
import * as hostRules from '../../../util/host-rules.ts';
import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts';
import type { Http } from '../../../util/http/index.ts';
import type {
HttpOptions,
HttpResponse,
OutgoingHttpHeaders,
} from '../../../util/http/types.ts';
import { refusedHostMessage } from '../../../util/http/util.ts';
import type { ParamsChallenge } from '../../../util/http/www-authenticate.ts';
import { BearerScheme, parse } from '../../../util/http/www-authenticate.ts';
import { coerceObject } from '../../../util/object.ts';
import { regEx } from '../../../util/regex.ts';
import { addSecretForSanitizing } from '../../../util/sanitize.ts';
import {
ensureTrailingSlash,
parseUrl,
trimTrailingSlash,
} from '../../../util/url.ts';
import { api as dockerVersioning } from '../../versioning/docker/index.ts';
import { getGoogleAuthToken } from '../util.ts';
import { ecrRegex, getECRAuthToken, isECRMaxResultsResponse } from './ecr.ts';
import { googleRegex } from './google.ts';
import type { OciHelmConfig } from './schema.ts';
import { RegistryAuthToken } from './schema.ts';
import type { RegistryRepository } from './types.ts';
export const dockerDatasourceId = 'docker';
export const imageUrlLabel = 'org.opencontainers.image.url';
export const sourceLabel = 'org.opencontainers.image.source';
export const sourceLabels = [sourceLabel, 'org.label-schema.vcs-url'] as const;
export const gitRefLabel = 'org.opencontainers.image.revision';
export const DOCKER_HUB = 'https://index.docker.io';
export function isDockerHost(host: string): boolean {
const regex = regEx(/(?:^|\.)docker\.io$/);
return regex.test(host);
}
export async function getAuthHeaders(
http: Http,
registryHost: string,
dockerRepository: string,
apiCheckUrl = `${registryHost}/v2/`,
): Promise<OutgoingHttpHeaders | undefined> {
try {
const options = {
throwHttpErrors: false,
noAuth: true,
cacheProvider: memCacheProvider,
};
const apiCheckResponse = apiCheckUrl.endsWith('/v2/')
? await http.get(apiCheckUrl, options)
: // use json request, as this will be cached for tags, so it returns json
// TODO: add cache test
await http.getJsonUnchecked(apiCheckUrl, options);
if (apiCheckResponse.statusCode === 200) {
logger.debug(`No registry auth required for ${apiCheckUrl}`);
return {};
}
if (apiCheckResponse.statusCode === 404) {
logger.debug(`Page Not Found ${apiCheckUrl}`);
// throw error up to be caught and potentially retried with library/ prefix
throw new Error(PAGE_NOT_FOUND_ERROR);
}
// Some ECR-compatible private registries (e.g. corporate Docker proxies) reject
// n>1000 with 405 even on the auth-probe request. Fall back to probing the base
// /v2/ endpoint so getAuthHeaders can still obtain a valid token; the main fetch
// loop already retries with n=1000 when it encounters this same error.
if (isECRMaxResultsResponse(apiCheckResponse)) {
logger.debug(
{ apiCheckUrl },
'Registry rejected n>1000 on auth probe; retrying auth check via base /v2/ endpoint',
);
return getAuthHeaders(http, registryHost, dockerRepository);
}
if (
apiCheckResponse.statusCode !== 401 ||
!isNonEmptyString(apiCheckResponse.headers['www-authenticate'])
) {
logger.warn(
{ apiCheckUrl, res: apiCheckResponse },
'Invalid registry response',
);
return undefined;
}
const rule = hostRules.find({
hostType: dockerDatasourceId,
url: apiCheckUrl,
});
const opts: HttpOptions = {};
if (ecrRegex.test(registryHost)) {
logger.once.debug(`hostRules: ecr auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using ecr auth for Docker registry`,
);
const [, region] = coerceArray(ecrRegex.exec(registryHost));
const auth = await getECRAuthToken(region, rule);
if (auth) {
opts.headers = { authorization: `Basic ${auth}` };
}
} else if (
googleRegex.test(registryHost) &&
isUndefined(rule.username) &&
isUndefined(rule.password) &&
isUndefined(rule.token)
) {
logger.once.debug(`hostRules: google auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using google auth for Docker registry`,
);
const auth = await getGoogleAuthToken();
if (auth) {
opts.headers = { authorization: `Basic ${auth}` };
} else {
logger.once.debug(
{ registryHost, dockerRepository },
'Could not get Google access token, using no auth',
);
}
} else if (rule.username && rule.password) {
logger.once.debug(`hostRules: basic auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository },
`Using basic auth for Docker registry`,
);
const auth = Buffer.from(`${rule.username}:${rule.password}`).toString(
'base64',
);
opts.headers = { authorization: `Basic ${auth}` };
} else if (rule.token) {
const authType = rule.authType ?? 'Bearer';
logger.once.debug(
`hostRules: ${authType} token auth for ${registryHost}`,
);
logger.trace(
{ registryHost, dockerRepository },
`Using ${authType} token for Docker registry`,
);
opts.headers = { authorization: `${authType} ${rule.token}` };
}
const challenges = parse(apiCheckResponse.headers['www-authenticate']);
const authenticateHeader = challenges.find(
(c): c is ParamsChallenge => c.scheme === BearerScheme,
);
// If realm isn't an url, we should directly use auth header
// Can happen when we get a Basic auth or some other auth type
// * WWW-Authenticate: Basic realm="Artifactory Realm"
// * Www-Authenticate: Basic realm="https://123456789.dkr.ecr.eu-central-1.amazonaws.com/",service="ecr.amazonaws.com"
// * www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"
// * www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
// * www-authenticate: Bearer realm="https://codeberg.org/v2/token",service="container_registry",scope="*",Basic realm="https://codeberg.org/v2",service="container_registry",scope="*"
if (
!authenticateHeader ||
!isString(authenticateHeader.params?.realm) ||
parseUrl(authenticateHeader.params.realm) === null
) {
logger.once.debug(`hostRules: testing direct auth for ${registryHost}`);
logger.trace(
{ registryHost, dockerRepository, authenticateHeader },
`Invalid realm, testing direct auth`,
);
return opts.headers ?? undefined;
}
// already guarded by above clause
const authUrl = parseUrl(`${authenticateHeader.params.realm}`)!;
// repo isn't known to server yet, so causing wrong scope `repository:user/image:pull`
if (
isString(authenticateHeader.params.scope) &&
!apiCheckUrl.endsWith('/v2/')
) {
authUrl.searchParams.append('scope', authenticateHeader.params.scope);
} else {
authUrl.searchParams.append(
'scope',
`repository:${dockerRepository}:pull`,
);
}
if (isString(authenticateHeader.params.service)) {
authUrl.searchParams.append('service', authenticateHeader.params.service);
}
logger.trace(
{ registryHost, dockerRepository, authUrl: authUrl.href },
`Obtaining docker registry token`,
);
opts.noAuth = true;
opts.cacheProvider = memCacheProvider;
const authResponse = (
await http.getJson(authUrl.href, opts, RegistryAuthToken)
).body;
const token = authResponse.token ?? authResponse.access_token;
/* v8 ignore next -- TODO: add test */
if (!token) {
logger.warn('Failed to obtain docker registry token');
return undefined;
}
// sanitize token
addSecretForSanitizing(token);
return {
authorization: `Bearer ${token}`,
};
} catch (err) /* istanbul ignore next */ {
/* v8 ignore if -- quay.io errors are swallowed pending #9604, not reproduced in specs */
if (err.host === 'quay.io') {
// TODO: debug why quay throws errors (#9604)
return undefined;
}
/* v8 ignore if -- registry auth rejection is logged and swallowed, not mocked in specs */
if (err.statusCode === 401) {
logger.debug(
{ registryHost, dockerRepository },
'Unauthorized docker lookup',
);
logger.debug({ err });
return undefined;
}
/* v8 ignore if -- registry permission rejection is logged and swallowed, not mocked in specs */
if (err.statusCode === 403) {
logger.debug(
{ registryHost, dockerRepository },
'Not allowed to access docker registry',
);
logger.debug({ err });
return undefined;
}
if (err.name === 'RequestError' && isDockerHost(registryHost)) {
throw new ExternalHostError(err);
}
/* v8 ignore if -- Docker Hub rate limiting maps to ExternalHostError, not mocked in specs */
if (err.statusCode === 429 && isDockerHost(registryHost)) {
throw new ExternalHostError(err);
}
/* v8 ignore if -- registry server errors map to ExternalHostError, not mocked in specs */
if (err.statusCode >= 500 && err.statusCode < 600) {
throw new ExternalHostError(err);
}
if (err.message === PAGE_NOT_FOUND_ERROR) {
throw err;
}
/* v8 ignore if -- hostRules-disabled host is swallowed silently, not mocked in specs */
if ([HOST_BLOCKED, HOST_DISABLED].includes(err.message)) {
logger.trace(
{ registryHost, dockerRepository, err },
refusedHostMessage(err),
);
return undefined;
}
logger.warn(
{ registryHost, dockerRepository, err },
'Error obtaining docker token',
);
return undefined;
}
}
export function getRegistryRepository(
packageName: string,
registryUrl: string,
): RegistryRepository {
if (registryUrl !== DOCKER_HUB) {
const registryEndingWithSlash = ensureTrailingSlash(
registryUrl.replace(regEx(/^https?:\/\//), ''),
);
if (packageName.startsWith(registryEndingWithSlash)) {
let registryHost = trimTrailingSlash(registryUrl);
if (!regEx(/^https?:\/\//).test(registryHost)) {
registryHost = `https://${registryHost}`;
}
let dockerRepository = packageName.replace(registryEndingWithSlash, '');
const fullUrl = `${registryHost}/${dockerRepository}`;
const parsedFullUrl = parseUrl(fullUrl);
if (!parsedFullUrl) {
return { registryHost, dockerRepository };
}
registryHost = parsedFullUrl.origin;
dockerRepository = parsedFullUrl.pathname.substring(1);
return {
registryHost,
dockerRepository,
};
}
}
let registryHost = registryUrl;
const split = packageName.split('/');
if (split.length > 1 && (split[0].includes('.') || split[0].includes(':'))) {
[registryHost] = split;
split.shift();
}
let dockerRepository = split.join('/');
if (!regEx(/^https?:\/\//).test(registryHost)) {
registryHost = `https://${registryHost}`;
}
const { path, base } = coerceObject(
regEx(/^(?<base>https:\/\/[^/]+)\/(?<path>.+)$/).exec(registryHost)?.groups,
);
if (base && path) {
registryHost = base;
dockerRepository = `${trimTrailingSlash(path)}/${dockerRepository}`;
}
registryHost = registryHost
.replace('https://docker.io', 'https://index.docker.io')
.replace('https://registry-1.docker.io', 'https://index.docker.io');
const opts = hostRules.find({
hostType: dockerDatasourceId,
url: registryHost,
});
if (opts?.insecureRegistry) {
registryHost = registryHost.replace('https', 'http');
}
if (registryHost.endsWith('.docker.io') && !dockerRepository.includes('/')) {
dockerRepository = `library/${dockerRepository}`;
}
return {
registryHost,
dockerRepository,
};
}
export function extractDigestFromResponseBody(
manifestResponse: HttpResponse,
): string {
return `sha256:${toSha256(manifestResponse.body)}`;
}
export function findLatestStable(tags: string[]): string | null {
let stable: string | null = null;
for (const tag of tags) {
if (!dockerVersioning.isValid(tag) || !dockerVersioning.isStable(tag)) {
continue;
}
if (!stable || dockerVersioning.isGreaterThan(tag, stable)) {
stable = tag;
}
}
return stable;
}
const chartRepo = regEx(/charts?|helm|helm-charts?/i);
function isPossibleChartRepo(url: string): boolean {
if (detectPlatform(url) === null) {
return false;
}
const parsed = parseGitUrl(url);
return chartRepo.test(parsed.name);
}
export function findHelmSourceUrl(release: OciHelmConfig): string | null {
if (release.home && isPossibleChartRepo(release.home)) {
return release.home;
}
if (!release.sources?.length) {
return null;
}
for (const url of release.sources) {
if (isPossibleChartRepo(url)) {
return url;
}
}
// fallback
return release.sources[0];
}