Inflate every receive-pack object under strict pack, size, and checksum bounds, resolve deltas against in-pack bases only, and scan the real decompressed payloads for runtime-token forms, private keys, SSH key material, and known provider token formats. Thin packs are rejected so no pushed content escapes the scan, and upstream Git exchanges now run under one absolute stream deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
273 lines
9.1 KiB
Python
273 lines
9.1 KiB
Python
"""Behavioral branch coverage for SCM broker protocol helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from email.message import Message
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_scm_broker_support import (
|
|
Response,
|
|
_load,
|
|
_receive_command,
|
|
)
|
|
|
|
|
|
def test_broker_redirect_status_and_bounded_read_helpers(monkeypatch):
|
|
broker = _load("scm_broker")
|
|
with pytest.raises(broker.PolicyError, match="redirects"):
|
|
broker.RejectRedirect().redirect_request(
|
|
object(), None, 302, "redirect", {}, "https://elsewhere.invalid"
|
|
)
|
|
|
|
class CodeOnly:
|
|
def getcode(self):
|
|
return 204
|
|
|
|
assert broker._status(type("Status", (), {"status": 200})()) == 200
|
|
assert broker._status(CodeOnly()) == 204
|
|
assert broker._status(object()) is None
|
|
|
|
with pytest.raises(broker.PolicyError, match="safe size"):
|
|
broker._read_bounded(io.BytesIO(b"x"), 4, -1)
|
|
with pytest.raises(broker.PolicyError, match="safe size"):
|
|
broker._read_bounded(io.BytesIO(b"xxxxx"), 4)
|
|
with pytest.raises(broker.PolicyError, match="safe size"):
|
|
broker._read_bounded(io.BytesIO(b"x"), 4, 2)
|
|
with pytest.raises(broker.PolicyError, match="deadline"):
|
|
broker._read_bounded(io.BytesIO(b"x"), 4, 1, deadline=0)
|
|
|
|
timeouts = []
|
|
assert (
|
|
broker._read_bounded(
|
|
io.BytesIO(b"xy"),
|
|
4,
|
|
2,
|
|
deadline=broker.time.monotonic() + 5,
|
|
set_timeout=timeouts.append,
|
|
)
|
|
== b"xy"
|
|
)
|
|
assert timeouts
|
|
|
|
|
|
def test_spool_bounded_rejects_bounds_early_eof_and_closes_on_read_error():
|
|
broker = _load("scm_broker")
|
|
for length in (-1, 5):
|
|
with pytest.raises(broker.PolicyError, match="safe size"):
|
|
broker._spool_bounded(
|
|
io.BytesIO(b"x"),
|
|
4,
|
|
length,
|
|
token="sentinel",
|
|
context="request",
|
|
deadline=999999999,
|
|
)
|
|
with pytest.raises(broker.PolicyError, match="ended early"):
|
|
broker._spool_bounded(
|
|
io.BytesIO(b"x"),
|
|
4,
|
|
2,
|
|
token="sentinel",
|
|
context="request",
|
|
deadline=999999999,
|
|
)
|
|
|
|
class Broken:
|
|
def read(self, _size):
|
|
raise OSError("broken stream")
|
|
|
|
with pytest.raises(OSError, match="broken"):
|
|
broker._spool_bounded(
|
|
Broken(), 4, 1, token="sentinel", context="request", deadline=999999999
|
|
)
|
|
|
|
|
|
class _HandlerInput:
|
|
def __init__(self, body: bytes, *, content_type="application/json", length=None):
|
|
self.headers = Message()
|
|
self.headers["Content-Type"] = content_type
|
|
if length is None:
|
|
length = len(body)
|
|
self.headers["Content-Length"] = str(length)
|
|
self.rfile = io.BytesIO(body)
|
|
self.connection = type(
|
|
"Connection", (), {"settimeout": lambda self, value: None}
|
|
)()
|
|
|
|
|
|
def test_control_json_loader_requires_fixed_json_object():
|
|
broker = _load("scm_broker")
|
|
handler = _HandlerInput(b"{}")
|
|
handler.headers["Transfer-Encoding"] = "chunked"
|
|
with pytest.raises(broker.PolicyError, match="chunked"):
|
|
broker._load_json(handler)
|
|
with pytest.raises(broker.PolicyError, match="must be JSON"):
|
|
broker._load_json(_HandlerInput(b"{}", content_type="text/plain"))
|
|
with pytest.raises(broker.PolicyError, match="must be an object"):
|
|
broker._load_json(_HandlerInput(b"[]"))
|
|
assert broker._load_json(_HandlerInput(b'{"path":"safe"}')) == {"path": "safe"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("target", "expected"),
|
|
[
|
|
(
|
|
"/git/atlas/cassandra.git/info/refs?service=git-upload-pack",
|
|
("cassandra", "info/refs", "git-upload-pack"),
|
|
),
|
|
(
|
|
"/git/atlas/cassandra.git/info/refs?service=git-receive-pack",
|
|
("cassandra", "info/refs", "git-receive-pack"),
|
|
),
|
|
(
|
|
"/git/atlas/cassandra.git/git-upload-pack",
|
|
("cassandra", "git-upload-pack", "git-upload-pack"),
|
|
),
|
|
(
|
|
"/git/atlas/cassandra.git/git-receive-pack",
|
|
("cassandra", "git-receive-pack", "git-receive-pack"),
|
|
),
|
|
],
|
|
)
|
|
def test_git_target_accepts_each_exact_smart_http_route(target, expected):
|
|
broker = _load("scm_broker")
|
|
assert broker._git_target(target) == expected
|
|
|
|
|
|
def test_content_length_and_credential_helpers_cover_safe_and_rejected_values():
|
|
broker = _load("scm_broker")
|
|
headers = Message()
|
|
headers["Content-Length"] = "0"
|
|
assert broker._content_length(headers, 1) == 0
|
|
forms = broker._credential_forms("sentinel")
|
|
assert len(forms) == 3 and forms[0] == b"sentinel"
|
|
zero = b"0" * 40
|
|
commit = b"1" * 40
|
|
line = zero + b" " + commit + b" refs/heads/hermes/x " + forms[2] + b"\n"
|
|
framed = f"{len(line) + 4:04x}".encode() + line + b"0000"
|
|
with pytest.raises(broker.PolicyError, match="credential material"):
|
|
broker._validate_receive_pack(framed, "sentinel")
|
|
|
|
|
|
def test_receive_pack_validation_rewinds_stream_and_accepts_bytes():
|
|
broker = _load("scm_broker")
|
|
body = _receive_command(b"0" * 40, b"1" * 40, b"refs/heads/hermes/rewind")
|
|
stream = io.BytesIO(body)
|
|
stream.seek(9)
|
|
broker._validate_receive_pack(stream, "sentinel")
|
|
assert stream.tell() == 0
|
|
broker._validate_receive_pack(body, "sentinel")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "match"),
|
|
[
|
|
(b"bad!", "framing"),
|
|
(b"0000", "no ref command"),
|
|
(b"0003", "framing"),
|
|
(b"0008abc", "framing"),
|
|
(b"0008a b\n0000", "ref command"),
|
|
],
|
|
)
|
|
def test_receive_pack_rejects_bad_framing_and_commands(body, match):
|
|
broker = _load("scm_broker")
|
|
with pytest.raises(broker.PolicyError, match=match):
|
|
broker._validate_receive_pack(body, "sentinel")
|
|
|
|
|
|
def _packet(old: bytes, new: bytes, ref: bytes, *, terminator=True) -> bytes:
|
|
command = old + b" " + new + b" " + ref + b"\n"
|
|
framed = f"{len(command) + 4:04x}".encode() + command
|
|
return framed + (b"0000" if terminator else b"")
|
|
|
|
|
|
def test_receive_pack_rejects_nonascii_many_commands_and_missing_terminator(
|
|
monkeypatch,
|
|
):
|
|
broker = _load("scm_broker")
|
|
zero = b"0" * 40
|
|
commit = b"1" * 40
|
|
with pytest.raises(broker.PolicyError, match="canonical ASCII"):
|
|
broker._validate_receive_pack(
|
|
_packet(zero, commit, b"refs/heads/hermes/\xff"), "sentinel"
|
|
)
|
|
command = _packet(zero, commit, b"refs/heads/hermes/fix", terminator=False)
|
|
with pytest.raises(broker.PolicyError, match="terminator"):
|
|
broker._validate_receive_pack(command, "sentinel")
|
|
limit = _load("receive_pack_scan").MAX_PUSH_COMMANDS
|
|
many = command * (limit + 1) + b"0000"
|
|
with pytest.raises(broker.PolicyError, match="too many"):
|
|
broker._validate_receive_pack(many, "sentinel")
|
|
|
|
|
|
def test_upstream_request_infers_byte_length_and_requires_stream_length():
|
|
broker = _load("scm_broker")
|
|
seen = []
|
|
|
|
def opener(request, timeout):
|
|
seen.append((request, timeout))
|
|
return Response(b"result", content_type="application/x-git-upload-pack-result")
|
|
|
|
assert (
|
|
broker._upstream_git_request(
|
|
"/atlas/cassandra.git/git-upload-pack",
|
|
method="POST",
|
|
body=b"request",
|
|
content_type=None,
|
|
expected_type="application/x-git-upload-pack-result",
|
|
token="sentinel",
|
|
opener=opener,
|
|
)
|
|
== b"result"
|
|
)
|
|
assert seen[0][0].get_header("Content-length") == "7"
|
|
with pytest.raises(broker.PolicyError, match="length is missing"):
|
|
broker._upstream_git_request(
|
|
"/atlas/cassandra.git/git-upload-pack",
|
|
method="POST",
|
|
body=io.BytesIO(b"request"),
|
|
content_type=None,
|
|
expected_type="application/x-git-upload-pack-result",
|
|
token="sentinel",
|
|
opener=opener,
|
|
)
|
|
|
|
|
|
def test_upstream_request_rejects_wrong_type_and_supports_getcode_status():
|
|
broker = _load("scm_broker")
|
|
|
|
class CodeOnly(Response):
|
|
def __init__(self, body, content_type):
|
|
super().__init__(body, content_type=content_type)
|
|
del self.status
|
|
|
|
def getcode(self):
|
|
return 200
|
|
|
|
assert (
|
|
broker._upstream_git_request(
|
|
"/atlas/cassandra.git/info/refs?service=git-upload-pack",
|
|
method="GET",
|
|
body=None,
|
|
content_type=None,
|
|
expected_type="application/x-git-upload-pack-advertisement",
|
|
token="sentinel",
|
|
opener=lambda *_a, **_k: CodeOnly(
|
|
b"advertisement", "application/x-git-upload-pack-advertisement"
|
|
),
|
|
)
|
|
== b"advertisement"
|
|
)
|
|
with pytest.raises(broker.PolicyError, match="response type"):
|
|
broker._upstream_git_request(
|
|
"/atlas/cassandra.git/info/refs?service=git-upload-pack",
|
|
method="GET",
|
|
body=None,
|
|
content_type=None,
|
|
expected_type="application/x-git-upload-pack-advertisement",
|
|
token="sentinel",
|
|
opener=lambda *_a, **_k: Response(b"bad", content_type="text/plain"),
|
|
)
|