You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(config): allow self-hosted admins to bypass allowedHeaders
In a similar way to how `allowedEnv` works, we should allow a
self-hosted admin to specify whichever headers they wish in their
`hostRules` - whether in their global config, a `repositories[]` entry,
or a preset a `repositories[]` entry extends.
Previously, this would require a self-hosted admin to add any header
they wished to set to `allowedHeaders`, which would also allow any of
their users' repositories to set that same header.
We can align this behaviour with `allowedEnv`, which makes sure that the
admin's own `headers` are always applied regardless of `allowedHeaders`,
while a repository's own `hostRules` - and those of any preset it
extends - remain constrained by it as before.
We do not currently allow an `extends` (without a `repositories[]`
entry) to bypass, and they'll be followed-up with in #45670.
Co-Authored-By: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
Co-Authored-By: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
Copy file name to clipboardExpand all lines: docs/usage/configuration-options.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2346,9 +2346,11 @@ By default, all headers starting with "X-" are allowed.
2346
2346
2347
2347
A self-hosted administrator may configure an override for [`allowedHeaders`](./self-hosted-configuration.md#allowedheaders) to configure more permitted headers.
2348
2348
2349
-
`headers`are checked against `allowedHeaders` wherever they are configured, including in the self-hosted administrator's own `hostRules` (for example in a `config.js` file).
2349
+
`headers`you configure in a repository config file, or a preset it extends, are checked against `allowedHeaders`.
2350
2350
Any header which is not permitted is dropped, and a warning is logged.
2351
2351
2352
+
`allowedHeaders` does not constrain the self-hosted administrator's own `hostRules` (for example in a `config.js` file, or a `repositories[]` entry): their `headers` are always applied, regardless of `allowedHeaders`.
2353
+
2352
2354
When more than one of your host rules matches a request, the `headers` of the most specific matching rule are used, and replace the `headers` of the broader rules it matched alongside.
2353
2355
2354
2356
A self-hosted administrator's own host rules are resolved the same way, and whichever `headers` that leaves them sending to the host are then applied on top of yours:
Copy file name to clipboardExpand all lines: docs/usage/self-hosted-configuration.md
+7-1Lines changed: 7 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -127,9 +127,15 @@ The `allowedHeaders` config option takes an array of minimatch-compatible globs
127
127
For more details on this syntax see Renovate's [string pattern matching documentation](./string-pattern-matching.md).
128
128
129
129
!!! note
130
+
`allowedHeaders` constrains what a repository, and the presets it extends, may set - it does not constrain you, the self-hosted administrator.
131
+
<br>
132
+
The `headers` you set in your own `hostRules` - whether in your global config or in a `repositories[]` entry - are always applied, regardless of `allowedHeaders`, as is any `headers` set by a preset a `repositories[]` entry chose to `extends`.
133
+
<br>
134
+
This does not (yet) extend to a preset your global config itself chooses to `extends` when no `repositories[]` entry is involved: those `headers` are still constrained by `allowedHeaders`, unlike the equivalent case for `env`.
135
+
<br>
130
136
Where more than one of your own rules matches a request, the `headers` of the most specific rule are used, and replace those of the broader rules it matched alongside.
131
137
So to keep a header away from a host that a broader rule of yours also matches, give that host a rule of its own which sets `headers`.
132
-
Whichever of your `headers` that leaves for a request are then applied over any a repository set, and always with the value you set: a repository's `hostRules` - or those of a preset it extends - can neither stop one of them being sent, nor replace its value.
138
+
Whichever of your `headers` that leaves for a request are then applied over any a repository set, and always with the value you set: a repository's `hostRules` - or those of a preset it extends - can neither stop one of them being sent, nor replace its value, and can only set a header name of its own where it is permitted by `allowedHeaders`.
it("filters the self-hosted admin's own hostRules headers against allowedHeaders",async()=>{
330
+
it("does not drop the self-hosted admin's own hostRules headers against allowedHeaders when registering them",async()=>{
331
+
// `allowedHeaders` constrains what a repository or preset may set, not the self-hosted administrator - `hostRules.add()` no longer filters (and warns about) the admin's own headers
332
+
//
333
+
// this does not (yet) extend to the separate, pre-existing top-level config security validation in `lib/config/validation.ts`, which still reports a global config's own `hostRules[].headers` outside its own `allowedHeaders` as a `Config security error` - the same limitation `allowedEnv`/`env` already have there, so `exitCode` is still 1 here
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
349
-
);
350
-
expect(all).toContain('Authorization');
351
-
});
352
-
});
353
-
354
-
it("honors a CLI-arg global config file's own allowedHeaders",async()=>{
355
-
// a global config file brings its own `allowedHeaders`, and its hostRules should be filtered against those - not the surrounding environment's - as a real run would after parsing it
@@ -42,9 +42,9 @@ async function partiallyGlobalInitialize(): Promise<void> {
42
42
GlobalConfig.set(globalConfig);
43
43
44
44
if(globalConfig.hostRules){
45
-
// the self-hosted admin's own headers get no exemption from `allowedHeaders` either, as `applyHostRule` filters the rule it matches by header name alone whoever set it - so `addHostRule` drops them here too, with a WARN, rather than leave them to be silently discarded at request time
45
+
// this is the self-hosted admin's own config, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`
46
46
for(consthostRuleofglobalConfig.hostRules){
47
-
addHostRule(hostRule);
47
+
addHostRule(hostRule,{trusted: true});
48
48
}
49
49
}
50
50
}
@@ -57,15 +57,9 @@ async function validate(
57
57
isPreset=false,
58
58
): Promise<void>{
59
59
if(config.hostRules){
60
-
// `allowedHeaders` enforces the checks regardless of whether it's global self-hosted administrator config, or repo config
61
-
// a global config file being validated brings its own `allowedHeaders`, and should be validated against it, like a real run would after parsing it
62
-
constallowedHeaders=
63
-
configType==='global'
64
-
? ((configasAllConfig).allowedHeaders??
65
-
GlobalConfig.get('allowedHeaders'))
66
-
: GlobalConfig.get('allowedHeaders');
60
+
// a `global` config is the self-hosted administrator's own, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`; a `repo` config's `hostRules` are still constrained by this instance's `allowedHeaders`
it('prefers an explicitly-passed allowlist over GlobalConfig',()=>{
206
-
// used when registering rules for a repository before `GlobalConfig` reflects it, i.e. a `repositories[]` entry's own `allowedHeaders` override
205
+
it('does not filter a trusted rule against allowedHeaders',()=>{
206
+
// `allowedHeaders` constrains what a repository or preset may set, not the self-hosted administrator - mirrors `allowedEnv`'s exemption of the admin's own `env`
it('returns a truly empty object for no match, not one with an undefined trustedHeaderNames',()=>{
300
+
// `toEqual({})` alone would not catch this: it ignores `undefined`-valued properties, but callers elsewhere use `Object.keys(...).length`/`isNonEmptyObject` to detect an empty result
/** whether the rule came from the self-hosted administrator's own global config, rather than from repository or preset config */
18
18
trusted?: boolean;
19
+
/** set only by {@link find}; see {@link CombinedHostRuleWithTrustedHeaders.trustedHeaderNames} */
20
+
trustedHeaderNames?: string[];
21
+
}
22
+
23
+
/**
24
+
* A {@link CombinedHostRule} returned by {@link find}, additionally reporting which of its `headers` came from a `trusted` rule.
25
+
*
26
+
* `trustedHeaderNames` is deliberately not a field of `HostRule`, for the same reason `trusted` is not: it must never be settable through configuration.
/** header names in `headers` that came from a `trusted` rule (the self-hosted administrator's own config), and so already bypassed `allowedHeaders` at registration - `applyHostRule`'s request-time defence-in-depth must not re-check, and drop, them */
* Enforce the `allowedHeaders` allowlist on a set of host rules.
72
84
*
73
-
* Loudly remove anything that's not permitted, logging a WARN.
74
-
*
75
-
* `add()` applies this to every rule it registers, so callers only need it themselves to pre-filter - e.g. to avoid repeating the WARN when the same rules are registered again and again.
76
-
*
77
-
* @param [allowedHeaders] the effective allowlist. Defaults to `GlobalConfig`, but must be passed explicitly when filtering before `GlobalConfig` reflects the repository being processed, i.e. for a `repositories[]` entry's own `allowedHeaders` override
78
-
* @param [warnOnDenied=true] whether to log the WARN. Pass `false` where the very same rules are filtered again, so that it is logged once rather than repeated
85
+
* Loudly remove anything that's not permitted, logging a WARN. Used by `add()` for an untrusted rule's `headers`; a `trusted` rule's are exempt, so never reach this.
79
86
*
80
87
* Headers that survive this allowlist are still subject to how {@link find} combines them: an admin's headers for a host are applied over those of any repository or preset rule matching the same request, so a repository can neither drop nor substitute them.
81
88
*/
82
-
exportfunctionfilterAllowedHeaders(
83
-
rules: HostRule[],
84
-
allowedHeaders?: string[],
85
-
warnOnDenied=true,
86
-
): HostRule[]{
87
-
// `??`, rather than a parameter default: a default only applies to `undefined`, and a `null` can reach us from user config
@@ -116,7 +118,7 @@ export function filterAllowedHeaders(
116
118
returnfiltered;
117
119
});
118
120
119
-
if(denied.length&&warnOnDenied){
121
+
if(denied.length){
120
122
logger.warn(
121
123
{ denied },
122
124
"Ignoring hostRules headers not permitted by this Renovate instance's `allowedHeaders`",
@@ -126,9 +128,6 @@ export function filterAllowedHeaders(
126
128
}
127
129
128
130
exportinterfaceAddHostRuleOptions{
129
-
/** the effective allowlist. Defaults to `GlobalConfig`; pass it explicitly when `GlobalConfig` does not yet reflect the repository the rule is registered for */
130
-
allowedHeaders?: string[];
131
-
132
131
/**
133
132
* Whether this rule comes from the self-hosted administrator's own global config, rather than from repository or preset config.
// set only from `options`, and dropped first so that it cannot be carried over from `params`: `HostRule` has no `trusted` field, but configuration is parsed from JSON, so a repository could otherwise smuggle one in and have its headers treated as the administrator's
142
+
// set only from `options`, and dropped first so that it cannot be carried over from `params`: `HostRule` has no `trusted`/`trustedHeaderNames` field, but configuration is parsed from JSON, so a repository could otherwise smuggle one in and have its headers treated as the administrator's
144
143
deleterule.trusted;
144
+
deleterule.trustedHeaderNames;
145
145
if(options?.trusted){
146
146
rule.trusted=true;
147
147
}
148
148
149
-
if(rule.headers){
149
+
if(rule.headers&&!rule.trusted){
150
150
// enforced here, at the single registration chokepoint, so that no current or future caller can register a rule whose headers bypass `allowedHeaders`; `applyHostRule` filters by header name again at request time as defence in depth
// `allowedHeaders` constrains what a repository or preset may set, not the self-hosted administrator - the same exemption `filterAllowedEnv` gives the admin's own `env`, just enforced through `trusted` here rather than a name+value comparison, as `headers` are host-scoped
152
+
[rule]=filterAllowedHeaders([rule]);
152
153
}
153
154
154
155
if(rule.matchHost){
@@ -258,7 +259,9 @@ function headersOfLastRuleToSetThem(
// set as a pair with `headers`, even to `undefined`, rather than only when there are trusted headers: `findMatchingRule`'s hostType fallbacks build on `find()` with `{ ...fallbackResult, ...res }`, so `res`'s own `trustedHeaderNames` must shadow a fallback's own whenever `res.headers` does the same to a fallback's `headers` - otherwise a fallback's `trustedHeaderNames` could survive alongside `res`'s own, unrelated `headers`
323
+
// never set when `res.headers` isn't either, so a `find()` with no matching headers still returns `{}` rather than `{ trustedHeaderNames: undefined }`, which callers that check for an empty result (e.g. `isNonEmptyObject`) rely on
324
+
// tracked so that `applyHostRule`'s request-time defence-in-depth knows which of `res.headers` already bypassed `allowedHeaders` at registration as the administrator's own, and does not re-drop them
0 commit comments