Skip to content

Commit cccdd34

Browse files
hersentinozT-1337
authored andcommitted
feat(config): implement custom header field inside HostRules (renovatebot#26225)
1 parent 3f0e961 commit cccdd34

File tree

10 files changed

+208
-0
lines changed

10 files changed

+208
-0
lines changed

docs/usage/configuration-options.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,30 @@ It uses `QuickLRU` with a `maxSize` of `1000`.
18101810

18111811
Enable got [http2](https://github.com/sindresorhus/got/blob/v11.5.2/readme.md#http2) support.
18121812

1813+
### headers
1814+
1815+
You can provide a `headers` object that includes fields to be forwarded to the HTTP request headers.
1816+
By default, all headers starting with "X-" are allowed.
1817+
1818+
A bot administrator may configure an override for [`allowedHeaders`](./self-hosted-configuration.md#allowedHeaders) to configure more permitted headers.
1819+
1820+
`headers` value(s) configured in the bot admin `hostRules` (for example in a `config.js` file) are _not_ validated, so it may contain any header regardless of `allowedHeaders`.
1821+
1822+
For example:
1823+
1824+
```json
1825+
{
1826+
"hostRules": [
1827+
{
1828+
"matchHost": "https://domain.com/all-versions",
1829+
"headers": {
1830+
"X-custom-header": "secret"
1831+
}
1832+
}
1833+
]
1834+
}
1835+
```
1836+
18131837
### hostType
18141838

18151839
`hostType` is another way to filter rules and can be either a platform such as `github` and `bitbucket-server`, or it can be a datasource such as `docker` and `rubygems`.

docs/usage/self-hosted-configuration.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ But before you disable templating completely, try the `allowedPostUpgradeCommand
6363

6464
## allowScripts
6565

66+
## allowedHeaders
67+
68+
`allowedHeaders` can be useful when a registry uses a authentication system that's not covered by Renovate's default credential handling in `hostRules`.
69+
By default, all headers starting with "X-" are allowed.
70+
If needed, you can allow additional headers with the `allowedHeaders` option.
71+
Any set `allowedHeaders` overrides the default "X-" allowed headers, so you should include them in your config if you wish for them to remain allowed.
72+
The `allowedHeaders` config option takes an array of minimatch-compatible globs or re2-compatible regex strings.
73+
74+
Examples:
75+
76+
| Example header | Kind of pattern | Explanation |
77+
| -------------- | ---------------- | ------------------------------------------- |
78+
| `/X/` | Regex | Any header with `x` anywhere in the name |
79+
| `!/X/` | Regex | Any header without `X` anywhere in the name |
80+
| `X-*` | Global pattern | Any header starting with `X-` |
81+
| `X` | Exact match glob | Only the header matching exactly `X` |
82+
83+
```json
84+
{
85+
"hostRules": [
86+
{
87+
"matchHost": "https://domain.com/all-versions",
88+
"headers": {
89+
"X-Auth-Token": "secret"
90+
}
91+
}
92+
]
93+
}
94+
```
95+
96+
Or with custom `allowedHeaders`:
97+
98+
```js title="config.js"
99+
module.exports = {
100+
allowedHeaders: ['custom-header'],
101+
};
102+
```
103+
66104
## allowedPostUpgradeCommands
67105

68106
A list of regular expressions that decide which commands in `postUpgradeTasks` are allowed to run.

lib/config/global.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export class GlobalConfig {
44
// TODO: once global config work is complete, add a test to make sure this list includes all options with globalOnly=true (#9603)
55
private static readonly OPTIONS: (keyof RepoGlobalConfig)[] = [
66
'allowCustomCrateRegistries',
7+
'allowedHeaders',
78
'allowedPostUpgradeCommands',
89
'allowPlugins',
910
'allowPostUpgradeCommandTemplating',

lib/config/options/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import { getVersioningList } from '../../modules/versioning';
55
import type { RenovateOptions } from '../types';
66

77
const options: RenovateOptions[] = [
8+
{
9+
name: 'allowedHeaders',
10+
description:
11+
'List of allowed patterns for header names in repository hostRules config.',
12+
type: 'array',
13+
default: ['X-*'],
14+
subType: 'string',
15+
globalOnly: true,
16+
},
817
{
918
name: 'detectGlobalManagerConfig',
1019
description:
@@ -2394,6 +2403,16 @@ const options: RenovateOptions[] = [
23942403
env: false,
23952404
advancedUse: true,
23962405
},
2406+
{
2407+
name: 'headers',
2408+
description:
2409+
'Put fields to be forwarded to the HTTP request headers in the headers config option.',
2410+
type: 'object',
2411+
parent: 'hostRules',
2412+
cli: false,
2413+
env: false,
2414+
advancedUse: true,
2415+
},
23972416
{
23982417
name: 'artifactAuth',
23992418
description:

lib/config/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export interface RepoGlobalConfig {
127127
allowPlugins?: boolean;
128128
allowPostUpgradeCommandTemplating?: boolean;
129129
allowScripts?: boolean;
130+
allowedHeaders?: string[];
130131
allowedPostUpgradeCommands?: string[];
131132
binarySource?: 'docker' | 'global' | 'install' | 'hermit';
132133
cacheHardTtlMinutes?: number;

lib/config/validation.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { GlobalConfig } from './global';
12
import type { RenovateConfig } from './types';
23
import * as configValidation from './validation';
34

@@ -1005,5 +1006,60 @@ describe('config/validation', () => {
10051006
},
10061007
]);
10071008
});
1009+
1010+
it('errors if forbidden header in hostRules', async () => {
1011+
GlobalConfig.set({ allowedHeaders: ['X-*'] });
1012+
1013+
const config = {
1014+
hostRules: [
1015+
{
1016+
matchHost: 'https://domain.com/all-versions',
1017+
headers: {
1018+
'X-Auth-Token': 'token',
1019+
unallowedHeader: 'token',
1020+
},
1021+
},
1022+
],
1023+
};
1024+
const { warnings, errors } = await configValidation.validateConfig(
1025+
false,
1026+
config,
1027+
);
1028+
expect(warnings).toHaveLength(0);
1029+
expect(errors).toMatchObject([
1030+
{
1031+
message:
1032+
"hostRules header `unallowedHeader` is not allowed by this bot's `allowedHeaders`.",
1033+
topic: 'Configuration Error',
1034+
},
1035+
]);
1036+
});
1037+
1038+
it('errors if headers values are not string', async () => {
1039+
GlobalConfig.set({ allowedHeaders: ['X-*'] });
1040+
1041+
const config = {
1042+
hostRules: [
1043+
{
1044+
matchHost: 'https://domain.com/all-versions',
1045+
headers: {
1046+
'X-Auth-Token': 10,
1047+
} as unknown as Record<string, string>,
1048+
},
1049+
],
1050+
};
1051+
const { warnings, errors } = await configValidation.validateConfig(
1052+
false,
1053+
config,
1054+
);
1055+
expect(warnings).toHaveLength(0);
1056+
expect(errors).toMatchObject([
1057+
{
1058+
message:
1059+
'Invalid hostRules headers value configuration: header must be a string.',
1060+
topic: 'Configuration Error',
1061+
},
1062+
]);
1063+
});
10081064
});
10091065
});

lib/config/validation.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import type {
77
RegexManagerTemplates,
88
} from '../modules/manager/custom/regex/types';
99
import type { CustomManager } from '../modules/manager/custom/types';
10+
import type { HostRule } from '../types/host-rules';
11+
import { anyMatchRegexOrMinimatch } from '../util/package-rules/match';
1012
import { configRegexPredicate, isConfigRegex, regEx } from '../util/regex';
1113
import * as template from '../util/template';
1214
import {
1315
hasValidSchedule,
1416
hasValidTimezone,
1517
} from '../workers/repository/update/branch/schedule';
18+
import { GlobalConfig } from './global';
1619
import { migrateConfig } from './migration';
1720
import { getOptions } from './options';
1821
import { resolveConfigPresets } from './presets';
@@ -38,6 +41,7 @@ const topLevelObjects = managerList;
3841

3942
const ignoredNodes = [
4043
'$schema',
44+
'headers',
4145
'depType',
4246
'npmToken',
4347
'packageFile',
@@ -696,6 +700,29 @@ export async function validateConfig(
696700
}
697701
}
698702
}
703+
704+
if (key === 'hostRules' && is.array(val)) {
705+
const allowedHeaders = GlobalConfig.get('allowedHeaders');
706+
for (const rule of val as HostRule[]) {
707+
if (!rule.headers) {
708+
continue;
709+
}
710+
for (const [header, value] of Object.entries(rule.headers)) {
711+
if (!is.string(value)) {
712+
errors.push({
713+
topic: 'Configuration Error',
714+
message: `Invalid hostRules headers value configuration: header must be a string.`,
715+
});
716+
}
717+
if (!anyMatchRegexOrMinimatch(allowedHeaders, header)) {
718+
errors.push({
719+
topic: 'Configuration Error',
720+
message: `hostRules header \`${header}\` is not allowed by this bot's \`allowedHeaders\`.`,
721+
});
722+
}
723+
}
724+
}
725+
}
699726
}
700727

701728
function sortAll(a: ValidationMessage, b: ValidationMessage): number {

lib/types/host-rules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface HostRuleSearchResult {
1111
enableHttp2?: boolean;
1212
concurrentRequestLimit?: number;
1313
maxRequestsPerSecond?: number;
14+
headers?: Record<string, string>;
1415
maxRetryAfter?: number;
1516

1617
dnsCache?: boolean;

lib/util/http/host-rules.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { GlobalConfig } from '../../config/global';
12
import { bootstrap } from '../../proxy';
23
import type { HostRule } from '../../types';
34
import * as hostRules from '../host-rules';
@@ -542,4 +543,21 @@ describe('util/http/host-rules', () => {
542543
username: undefined,
543544
});
544545
});
546+
547+
it('should remove forbidden headers from request', () => {
548+
GlobalConfig.set({ allowedHeaders: ['X-*'] });
549+
const hostRule = {
550+
matchHost: 'https://domain.com/all-versions',
551+
headers: {
552+
'X-Auth-Token': 'token',
553+
unallowedHeader: 'token',
554+
},
555+
};
556+
557+
expect(applyHostRule(url, {}, hostRule)).toEqual({
558+
headers: {
559+
'X-Auth-Token': 'token',
560+
},
561+
});
562+
});
545563
});

lib/util/http/host-rules.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import is from '@sindresorhus/is';
2+
import { GlobalConfig } from '../../config/global';
23
import {
34
BITBUCKET_API_USING_HOST_TYPES,
45
GITEA_API_USING_HOST_TYPES,
@@ -9,6 +10,7 @@ import { logger } from '../../logger';
910
import { hasProxy } from '../../proxy';
1011
import type { HostRule } from '../../types';
1112
import * as hostRules from '../host-rules';
13+
import { anyMatchRegexOrMinimatch } from '../package-rules/match';
1214
import { parseUrl } from '../url';
1315
import { dnsLookup } from './dns';
1416
import { keepAliveAgents } from './keep-alive';
@@ -162,6 +164,27 @@ export function applyHostRule<GotOptions extends HostRulesGotOptions>(
162164
options.lookup = dnsLookup;
163165
}
164166

167+
if (hostRule.headers) {
168+
const allowedHeaders = GlobalConfig.get('allowedHeaders');
169+
const filteredHeaders: Record<string, string> = {};
170+
171+
for (const [header, value] of Object.entries(hostRule.headers)) {
172+
if (anyMatchRegexOrMinimatch(allowedHeaders, header)) {
173+
filteredHeaders[header] = value;
174+
} else {
175+
logger.once.error(
176+
{ allowedHeaders, header },
177+
'Disallowed hostRules headers',
178+
);
179+
}
180+
}
181+
182+
options.headers = {
183+
...filteredHeaders,
184+
...options.headers,
185+
};
186+
}
187+
165188
if (hostRule.keepAlive) {
166189
options.agent = keepAliveAgents;
167190
}

0 commit comments

Comments
 (0)