From 7d3fe44060e6fd05088fbda17b330c0ffbbdae0b Mon Sep 17 00:00:00 2001 From: Jamie Tanna Date: Mon, 7 Sep 2026 15:32:42 +0100 Subject: [PATCH] fix(util/host-rules): resolve `enabled` per trust tier As part of follow-up changes, we're looking to tighten the way that Renovate handles outgoing HTTP traffic, and a self-hosted administrator disabling a host needs to be something a repository cannot undo. In a similar change to 4732da7d2b92ff2e31c87c0efc532c2fc067287f, we can resolve `enabled` per trust tier, so that a repository or preset rule can no longer re-enable - or disable - a host the administrator's own rules set, which was previously possible with a longer `matchHost`. Within a tier nothing changes: the most specific rule still wins, so a repository disabling a host of its own keeps working, as does an administrator masking their own broad rule with a narrower one. This is a behaviour change for repository config that previously relied on overriding an admin rule's `enabled`. Co-authored-by: Claude Fable 5 Co-authored-by: Claude Opus 5 --- docs/usage/configuration-options.md | 4 +++ lib/util/host-rules.spec.ts | 39 +++++++++++++++++++++++++++++ lib/util/host-rules.ts | 18 +++++++++++++ 3 files changed, 61 insertions(+) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index c308ba10bf..c987d8ca0e 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2182,6 +2182,10 @@ To match specific ports you have to add a protocol to `matchHost`: Disabling a host is only 100% effective if added to self-hosted config. Renovate currently still checks its _cache_ for results first before trying to connect, so if a public host is blocked in your repository config (e.g. `renovate.json`) then it's possible you may get cached _results_ from that host if another repository using the same Renovate deployment has successfully queried for the same dependency recently. +!!! note + `enabled` is not resolved by specificity alone: host rules from the self-hosted administrator's own config are resolved at a higher trust level than those from repository config or a preset. + So if the administrator's own rules set `enabled` for a host, repository config or a preset cannot re-enable - or disable - that host, no matter how specific its `matchHost` is. + ### `hostRules.abortIgnoreStatusCodes` This field can be used to configure status codes that Renovate ignores and passes through when `abortOnError` is set to `true`. diff --git a/lib/util/host-rules.spec.ts b/lib/util/host-rules.spec.ts index 3e96163cbb..5a3f5c39d7 100644 --- a/lib/util/host-rules.spec.ts +++ b/lib/util/host-rules.spec.ts @@ -690,6 +690,45 @@ describe('util/host-rules', () => { }); }); + describe('enabled trust tiers', () => { + it('does not let a more specific repository rule re-enable a host the admin disabled', () => { + add( + { matchHost: 'bad.example.com', enabled: false }, + { trusted: true }, + ); + add({ matchHost: 'https://bad.example.com/deeper', enabled: true }); + + expect( + find({ url: 'https://bad.example.com/deeper/path' }).enabled, + ).toBeFalse(); + }); + + it('does not let a repository rule disable a host the admin enabled', () => { + add( + { matchHost: 'good.example.com', enabled: true }, + { trusted: true }, + ); + add({ matchHost: 'https://good.example.com/deeper', enabled: false }); + + expect( + find({ url: 'https://good.example.com/deeper/path' }).enabled, + ).toBeTrue(); + }); + + it('keeps repository rules able to disable a host of their own', () => { + add({ matchHost: 'flaky.example.com', enabled: false }); + + expect(find({ url: 'https://flaky.example.com' }).enabled).toBeFalse(); + }); + + it('lets the most specific rule of a tier win', () => { + add({ matchHost: 'example.com', enabled: false }); + add({ matchHost: 'https://ok.example.com', enabled: true }); + + expect(find({ url: 'https://ok.example.com' }).enabled).toBeTrue(); + }); + }); + it('prefers the longest matchHost for a header they both set', () => { add({ matchHost: 'https://registry.example.com', diff --git a/lib/util/host-rules.ts b/lib/util/host-rules.ts index 2872e8d52b..ff86e54e3c 100644 --- a/lib/util/host-rules.ts +++ b/lib/util/host-rules.ts @@ -258,6 +258,13 @@ function headersOfLastRuleToSetThem( .pop(); } +/** + * The last defined value, following the same "most specific rule wins" ordering as {@link headersOfLastRuleToSetThem}. + */ +function lastDefined(values: (T | undefined)[]): T | undefined { + return values.filter((value) => !isUndefined(value)).pop(); +} + export function find(search: HostRuleSearch): CombinedHostRule { if ([search.hostType, search.url].every(isFalsy)) { logger.warn({ search }, 'Invalid hostRules search'); @@ -318,6 +325,17 @@ export function find(search: HostRuleSearch): CombinedHostRule { res.headers = { ...untrustedHeaders, ...trustedHeaders }; } + // `enabled` is resolved per trust tier like `headers`: a repository or preset rule must not be able to re-enable a host the administrator's own rules disabled (or vice versa) by out-specifying them with a longer `matchHost` + const trustedRules = matchedRules.filter((rule) => rule.trusted); + const untrustedEnabled = lastDefined( + matchedRules.filter((rule) => !rule.trusted).map((rule) => rule.enabled), + ); + const trustedEnabled = lastDefined(trustedRules.map((rule) => rule.enabled)); + const enabled = trustedEnabled ?? untrustedEnabled; + if (!isUndefined(enabled)) { + res.enabled = enabled; + } + delete res.hostType; delete res.resolvedHost; delete res.matchHost;