Skip to content

Commit beac0de

Browse files
committed
feat: add --http-header option for custom header injection
Allow users to inject custom HTTP headers into all outgoing requests via a repeatable --http-header flag. This enables bypassing proxies like Cloudflare Access without adding provider-specific flags.
1 parent 45e518e commit beac0de

4 files changed

Lines changed: 144 additions & 7 deletions

File tree

codecov-cli/codecov_cli/helpers/request.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,33 @@
1616

1717
USER_AGENT = f"codecov-cli/{__version__}"
1818

19+
_extra_headers: dict = {}
1920

20-
def _set_user_agent(headers: Optional[dict] = None) -> dict:
21+
22+
def set_extra_headers(headers: dict):
23+
global _extra_headers
24+
_extra_headers = dict(headers)
25+
26+
27+
def _prepare_headers(headers: Optional[dict] = None) -> dict:
2128
headers = headers or {}
22-
headers.setdefault("User-Agent", USER_AGENT)
23-
return headers
29+
merged = {**_extra_headers, **headers}
30+
merged["User-Agent"] = USER_AGENT
31+
return merged
2432

2533

2634
def patch(url: str, headers: dict = None, json: dict = None) -> requests.Response:
27-
headers = _set_user_agent(headers)
35+
headers = _prepare_headers(headers)
2836
return requests.patch(url, json=json, headers=headers)
2937

3038

3139
def get(url: str, headers: dict = None, params: dict = None) -> requests.Response:
32-
headers = _set_user_agent(headers)
40+
headers = _prepare_headers(headers)
3341
return requests.get(url, params=params, headers=headers)
3442

3543

3644
def put(url: str, data: dict = None, headers: dict = None) -> requests.Response:
37-
headers = _set_user_agent(headers)
45+
headers = _prepare_headers(headers)
3846
return requests.put(url, data=data, headers=headers)
3947

4048

@@ -44,7 +52,7 @@ def post(
4452
headers: Optional[dict] = None,
4553
params: Optional[dict] = None,
4654
) -> requests.Response:
47-
headers = _set_user_agent(headers)
55+
headers = _prepare_headers(headers)
4856
return requests.post(url, json=data, headers=headers, params=params)
4957

5058

codecov-cli/codecov_cli/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from codecov_cli.helpers.ci_adapters import get_ci_adapter, get_ci_providers_list
2323
from codecov_cli.helpers.config import load_cli_config
2424
from codecov_cli.helpers.logging_utils import configure_logger
25+
from codecov_cli.helpers.request import set_extra_headers
2526
from codecov_cli.helpers.versioning_systems import get_versioning_system
2627
from codecov_cli.opentelemetry import init_telem
2728

@@ -48,6 +49,11 @@
4849
@click.option(
4950
"--disable-telem", help="Disable sending telemetry data to Codecov", is_flag=True
5051
)
52+
@click.option(
53+
"--http-header",
54+
multiple=True,
55+
help="Extra HTTP header to send with every request (format: Header-Name:Value). Can be specified multiple times.",
56+
)
5157
@click.pass_context
5258
@click.version_option(__version__, prog_name="codecovcli")
5359
def cli(
@@ -57,6 +63,7 @@ def cli(
5763
enterprise_url: str,
5864
verbose: bool = False,
5965
disable_telem: bool = False,
66+
http_header: typing.Tuple[str, ...] = (),
6067
):
6168
ctx.obj["cli_args"] = ctx.params
6269
ctx.obj["cli_args"]["version"] = f"cli-{__version__}"
@@ -72,6 +79,17 @@ def cli(
7279
ctx.obj["enterprise_url"] = enterprise_url
7380
ctx.obj["disable_telem"] = disable_telem
7481
ctx.obj["branding"] = [Branding.CODECOV]
82+
if http_header:
83+
extra = {}
84+
for h in http_header:
85+
if ":" not in h:
86+
raise click.BadParameter(
87+
f"Invalid header format: '{h}'. Expected 'Header-Name:Value'.",
88+
param_hint="'--http-header'",
89+
)
90+
name, value = h.split(":", 1)
91+
extra[name.strip()] = value.strip()
92+
set_extra_headers(extra)
7593
init_telem(ctx.obj)
7694

7795

codecov-cli/tests/helpers/test_request.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
from requests import Response
66

77
from codecov_cli import __version__
8+
from codecov_cli.helpers import request as request_module
89
from codecov_cli.helpers.request import (
10+
_prepare_headers,
911
get,
1012
get_token_header,
1113
get_token_header_or_fail,
1214
log_warnings_and_errors_if_any,
15+
set_extra_headers,
1316
)
1417
from codecov_cli.helpers.request import logger as req_log
1518
from codecov_cli.helpers.request import (
@@ -186,3 +189,65 @@ def mock_request(*args, headers={}, **kwargs):
186189
side_effect=mock_request,
187190
)
188191
patch("my_url")
192+
193+
194+
class TestExtraHeaders:
195+
@pytest.fixture(autouse=True)
196+
def reset_extra_headers(self):
197+
set_extra_headers({})
198+
yield
199+
set_extra_headers({})
200+
201+
def test_prepare_headers_without_extra(self):
202+
headers = _prepare_headers()
203+
assert headers == {"User-Agent": f"codecov-cli/{__version__}"}
204+
205+
def test_prepare_headers_with_extra(self):
206+
set_extra_headers({"CF-Access-Client-Id": "abc123"})
207+
headers = _prepare_headers()
208+
assert headers["CF-Access-Client-Id"] == "abc123"
209+
assert headers["User-Agent"] == f"codecov-cli/{__version__}"
210+
211+
def test_extra_headers_dont_overwrite_authorization(self):
212+
set_extra_headers({"Authorization": "evil"})
213+
headers = _prepare_headers({"Authorization": "token real-token"})
214+
assert headers["Authorization"] == "token real-token"
215+
216+
def test_extra_headers_dont_overwrite_user_agent(self):
217+
set_extra_headers({"User-Agent": "custom-agent"})
218+
headers = _prepare_headers()
219+
assert headers["User-Agent"] == f"codecov-cli/{__version__}"
220+
221+
def test_extra_headers_merged_into_post(self, mocker):
222+
set_extra_headers({"X-Custom": "value"})
223+
224+
def mock_post(*args, headers=None, **kwargs):
225+
assert headers["X-Custom"] == "value"
226+
assert headers["User-Agent"] == f"codecov-cli/{__version__}"
227+
resp = Response()
228+
resp.status_code = 200
229+
resp._content = b"ok"
230+
return resp
231+
232+
mocker.patch.object(requests, "post", side_effect=mock_post)
233+
send_post_request("my_url")
234+
235+
def test_extra_headers_merged_into_get(self, mocker):
236+
set_extra_headers({"X-Custom": "value"})
237+
238+
def mock_get(*args, headers=None, **kwargs):
239+
assert headers["X-Custom"] == "value"
240+
resp = Response()
241+
resp.status_code = 200
242+
resp._content = b"ok"
243+
return resp
244+
245+
mocker.patch.object(requests, "get", side_effect=mock_get)
246+
get("my_url")
247+
248+
def test_set_extra_headers_replaces_previous(self):
249+
set_extra_headers({"A": "1"})
250+
set_extra_headers({"B": "2"})
251+
headers = _prepare_headers()
252+
assert "A" not in headers
253+
assert headers["B"] == "2"

codecov-cli/tests/test_codecov_cli.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import pytest
2+
from click.testing import CliRunner
3+
14
from codecov_cli import main
5+
from codecov_cli.helpers import request as request_module
26

37

48
def test_existing_commands():
@@ -17,3 +21,45 @@ def test_existing_commands():
1721
"upload-coverage",
1822
"upload-process",
1923
]
24+
25+
26+
class TestHttpHeaderOption:
27+
@pytest.fixture(autouse=True)
28+
def reset_extra_headers(self):
29+
request_module._extra_headers = {}
30+
yield
31+
request_module._extra_headers = {}
32+
33+
def test_http_header_valid(self):
34+
runner = CliRunner()
35+
result = runner.invoke(
36+
main.cli,
37+
[
38+
"--http-header",
39+
"CF-Access-Client-Id:abc123",
40+
"--http-header",
41+
"CF-Access-Client-Secret:xyz789",
42+
"--help",
43+
],
44+
obj={},
45+
)
46+
assert result.exit_code == 0
47+
48+
def test_http_header_invalid_format(self):
49+
runner = CliRunner()
50+
result = runner.invoke(
51+
main.cli,
52+
["--http-header", "InvalidHeader", "do-upload", "--help"],
53+
obj={},
54+
)
55+
assert result.exit_code != 0
56+
assert "Invalid header format" in result.output
57+
58+
def test_http_header_value_with_colon(self):
59+
runner = CliRunner()
60+
result = runner.invoke(
61+
main.cli,
62+
["--http-header", "X-Test:value:with:colons", "--help"],
63+
obj={},
64+
)
65+
assert result.exit_code == 0

0 commit comments

Comments
 (0)