mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
29a7b9c5f5
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>
276 lines
6.8 KiB
TypeScript
276 lines
6.8 KiB
TypeScript
import { isNonEmptyString } from '@sindresorhus/is';
|
|
import { GlobalConfig } from '../../config/global.ts';
|
|
import {
|
|
BITBUCKET_API_USING_HOST_TYPES,
|
|
BITBUCKET_SERVER_API_USING_HOST_TYPES,
|
|
FORGEJO_API_USING_HOST_TYPES,
|
|
GITEA_API_USING_HOST_TYPES,
|
|
GITHUB_API_USING_HOST_TYPES,
|
|
GITLAB_API_USING_HOST_TYPES,
|
|
} from '../../constants/index.ts';
|
|
import { logger } from '../../logger/index.ts';
|
|
import { hasProxy } from '../../proxy.ts';
|
|
import type { CombinedHostRule, HostRule } from '../../types/index.ts';
|
|
import * as hostRules from '../host-rules.ts';
|
|
import { matchRegexOrGlobList } from '../string-match.ts';
|
|
import { parseUrl } from '../url.ts';
|
|
import type { InternalHttpOptions } from './http.ts';
|
|
import { keepAliveAgents } from './keep-alive.ts';
|
|
import type { GotOptions } from './types.ts';
|
|
|
|
export type HostRulesGotOptions = Pick<
|
|
GotOptions & InternalHttpOptions,
|
|
| 'hostType'
|
|
| 'noAuth'
|
|
| 'headers'
|
|
| 'token'
|
|
| 'username'
|
|
| 'password'
|
|
| 'context'
|
|
| 'enabled'
|
|
| 'abortOnError'
|
|
| 'abortIgnoreStatusCodes'
|
|
| 'timeout'
|
|
| 'agent'
|
|
| 'http2'
|
|
| 'https'
|
|
| 'readOnly'
|
|
>;
|
|
|
|
export function findMatchingRule<GotOptions extends HostRulesGotOptions>(
|
|
url: string,
|
|
options: GotOptions,
|
|
): CombinedHostRule {
|
|
const { hostType, readOnly } = options;
|
|
let res = hostRules.find({ hostType, url, readOnly });
|
|
|
|
if (
|
|
isNonEmptyString(res.token) ||
|
|
isNonEmptyString(res.username) ||
|
|
isNonEmptyString(res.password)
|
|
) {
|
|
// do not fallback if we already have auth infos
|
|
return res;
|
|
}
|
|
|
|
// Fallback to `github` hostType
|
|
if (
|
|
hostType &&
|
|
GITHUB_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'github'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'github',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
const platform = GlobalConfig.get('platform');
|
|
const platformEndpoint = GlobalConfig.get('endpoint');
|
|
|
|
// in the case that an API URL is used for GitHub.com, fallback to `github` hostType, and use the `url`'s host to find a `matchHost: api.github.com` (or `matchHost: github.com`)
|
|
if (url.startsWith('https://api.github.com/')) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'github',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
} else if (platform === 'github' && platformEndpoint) {
|
|
// Fallback to `github` hostType when the request URL targets the same host
|
|
// as the configured GitHub platform endpoint.
|
|
//
|
|
// This covers GitHub Enterprise Server without hardcoding URLs.
|
|
const requestHost = parseUrl(url)?.hostname;
|
|
const endpointHost = parseUrl(platformEndpoint)?.hostname;
|
|
if (requestHost && endpointHost && requestHost === endpointHost) {
|
|
res = {
|
|
...hostRules.find({ hostType: 'github', url }),
|
|
...res,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Fallback to `gitlab` hostType
|
|
if (
|
|
hostType &&
|
|
GITLAB_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'gitlab'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'gitlab',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
// Fallback to `bitbucket` hostType
|
|
if (
|
|
hostType &&
|
|
BITBUCKET_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'bitbucket'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'bitbucket',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
// Fallback to `bitbucket-server` hostType
|
|
if (
|
|
hostType &&
|
|
BITBUCKET_SERVER_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'bitbucket-server'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'bitbucket-server',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
// Fallback to `forgejo` hostType
|
|
if (
|
|
hostType &&
|
|
FORGEJO_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'forgejo'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'forgejo',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
// Fallback to `gitea` hostType
|
|
if (
|
|
hostType &&
|
|
GITEA_API_USING_HOST_TYPES.includes(hostType) &&
|
|
hostType !== 'gitea'
|
|
) {
|
|
res = {
|
|
...hostRules.find({
|
|
hostType: 'gitea',
|
|
url,
|
|
}),
|
|
...res,
|
|
};
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
// Apply host rules to requests
|
|
export function applyHostRule<GotOptions extends HostRulesGotOptions>(
|
|
url: string,
|
|
options: GotOptions,
|
|
hostRule: HostRule,
|
|
): GotOptions {
|
|
if (hostRule.enabled === false) {
|
|
options.enabled = false;
|
|
return options;
|
|
}
|
|
|
|
const { username, password, token, authType } = hostRule;
|
|
const host = parseUrl(url)?.host;
|
|
if (options.noAuth) {
|
|
logger.trace({ url }, `Authorization disabled`);
|
|
} else if (
|
|
isNonEmptyString(options.headers?.authorization) ||
|
|
isNonEmptyString(options.password) ||
|
|
isNonEmptyString(options.token)
|
|
) {
|
|
logger.once.debug(`hostRules: authentication already set for ${host}`);
|
|
logger.trace({ url }, `Authorization already set`);
|
|
} else if (password !== undefined) {
|
|
logger.once.debug(`hostRules: applying Basic authentication for ${host}`);
|
|
logger.trace({ url }, `Applying Basic authentication`);
|
|
options.username = username;
|
|
options.password = password;
|
|
} else if (token) {
|
|
logger.once.debug(`hostRules: applying Bearer authentication for ${host}`);
|
|
logger.trace({ url }, `Applying Bearer authentication`);
|
|
options.token = token;
|
|
options.context = { ...options.context, authType };
|
|
} else {
|
|
logger.once.debug(`hostRules: no authentication for ${host}`);
|
|
}
|
|
// Apply optional params
|
|
if (hostRule.abortOnError) {
|
|
options.abortOnError = hostRule.abortOnError;
|
|
}
|
|
|
|
if (hostRule.abortIgnoreStatusCodes) {
|
|
options.abortIgnoreStatusCodes = hostRule.abortIgnoreStatusCodes;
|
|
}
|
|
|
|
if (hostRule.timeout) {
|
|
options.timeout = hostRule.timeout;
|
|
}
|
|
|
|
if (hostRule.headers) {
|
|
const allowedHeaders = GlobalConfig.get('allowedHeaders');
|
|
const filteredHeaders: Record<string, string> = {};
|
|
|
|
for (const [header, value] of Object.entries(hostRule.headers)) {
|
|
if (matchRegexOrGlobList(header, allowedHeaders)) {
|
|
filteredHeaders[header] = value;
|
|
} else {
|
|
logger.once.error(
|
|
{ allowedHeaders, header },
|
|
'Disallowed hostRules headers',
|
|
);
|
|
}
|
|
}
|
|
|
|
options.headers = {
|
|
...options.headers,
|
|
...filteredHeaders,
|
|
};
|
|
}
|
|
|
|
if (hostRule.keepAlive) {
|
|
options.agent = keepAliveAgents;
|
|
}
|
|
|
|
if (!hasProxy() && hostRule.enableHttp2 === true) {
|
|
options.http2 = true;
|
|
}
|
|
|
|
if (isNonEmptyString(hostRule.httpsCertificateAuthority)) {
|
|
options.https = {
|
|
...options.https,
|
|
certificateAuthority: hostRule.httpsCertificateAuthority,
|
|
};
|
|
}
|
|
|
|
if (isNonEmptyString(hostRule.httpsPrivateKey)) {
|
|
options.https = {
|
|
...options.https,
|
|
key: hostRule.httpsPrivateKey,
|
|
};
|
|
}
|
|
|
|
if (isNonEmptyString(hostRule.httpsCertificate)) {
|
|
options.https = {
|
|
...options.https,
|
|
certificate: hostRule.httpsCertificate,
|
|
};
|
|
}
|
|
|
|
return options;
|
|
}
|