Skip to content

Commit 0536679

Browse files
monitoring: validate report contents before the reader uses them
The body of an unsigned report is whatever the untrusted server chooses to serve, but the reader took its shape on faith. A body that is not a JSON object crashed borg monitor with an AttributeError traceback, a list where a string belongs (e.g. "archive") crashed it with an unhashable-type TypeError when grouping, and a report without a status was neither an error nor a warning - so it silently counted as a success. deserialize() now shape-checks every report: the fields the reader relies on (command, time, status, rc) must be present and of the right type, the optional ones (hostname, username, archive, archive_id, stats) must match their type if present, and status must be one of success/warning/error. Anything else is refused with a clean error. Reports from a real client always pass, so this only affects bodies nobody signed. An unparsable *value* in the time field is still tolerated (the report is skipped with a warning, see the previous commit); this is about the shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7155328 commit 0536679

3 files changed

Lines changed: 94 additions & 2 deletions

File tree

src/borg/monitoring.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
A plaintext body is only legitimate for an unencrypted repository, which has no key to
2727
sign with. Once a monitoring key is configured, plaintext bodies are refused rather than
2828
reported as untrusted - otherwise the server could downgrade a failure it wants hidden
29-
into a mere warning by replacing the sealed report with an unsigned "success".
29+
into a mere warning by replacing the sealed report with an unsigned "success". What an
30+
unsigned body does contain is not trusted either: every report is shape-checked before
31+
the reader touches it, see validate_report().
3032
"""
3133

3234
import json
@@ -109,6 +111,35 @@ def build_report(
109111
return report
110112

111113

114+
# Fields the reader relies on: required ones must be present and of the given type,
115+
# optional ones only have to match their type if they are present at all. Anything else a
116+
# report carries is passed through untouched.
117+
REQUIRED_FIELDS = {"command": str, "time": str, "status": str, "rc": int}
118+
OPTIONAL_FIELDS = {"hostname": str, "username": str, "archive": str, "archive_id": str, "stats": dict}
119+
STATUS_VALUES = {"success", "warning", "error"}
120+
121+
122+
def validate_report(report):
123+
"""Return *report* if the reader can safely work with it, else raise ValueError.
124+
125+
The body of an unsigned report is whatever the untrusted server chooses to serve, so
126+
its shape must not be taken on faith: a non-object body or a list where a string
127+
belongs crashes borg monitor with a traceback, and a missing status would pass as a
128+
success. A sealed report from a real client always satisfies this.
129+
"""
130+
if not isinstance(report, dict):
131+
raise ValueError("monitoring report: not a JSON object")
132+
for field, field_type in REQUIRED_FIELDS.items():
133+
if not isinstance(report.get(field), field_type):
134+
raise ValueError(f"monitoring report: missing or invalid {field!r}")
135+
for field, field_type in OPTIONAL_FIELDS.items():
136+
if field in report and not isinstance(report[field], field_type):
137+
raise ValueError(f"monitoring report: invalid {field!r}")
138+
if report["status"] not in STATUS_VALUES:
139+
raise ValueError(f"monitoring report: unknown status {report['status']!r}")
140+
return report
141+
142+
112143
def report_aad(repo_id_bin, name):
113144
"""Additional authenticated data a sealed report is bound to.
114145
@@ -165,7 +196,7 @@ def deserialize(monitor_key, repo_id_bin, name, data):
165196
trusted = False
166197
else:
167198
raise ValueError("monitoring report: unknown body type")
168-
return json.loads(payload.decode("utf-8")), trusted
199+
return validate_report(json.loads(payload.decode("utf-8"))), trusted
169200

170201

171202
def publish(repository, key, report):

src/borg/testsuite/archiver/monitor_cmd_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,20 @@ def test_injected_unsigned_report_is_refused(archivers, request, monkeypatch):
282282
assert "unsigned" in output
283283

284284

285+
def test_malformed_unsigned_report_gives_a_clean_error(archivers, request):
286+
archiver = request.getfixturevalue(archivers)
287+
create_regular_file(archiver.input_path, "file1", contents=b"some data")
288+
cmd(archiver, "repo-create", "--encryption=none")
289+
cmd(archiver, "create", "series", "input")
290+
# an unencrypted repo's reports are unsigned, so their contents are whatever the
291+
# server serves: borg monitor must refuse them, not crash on them
292+
name = sorted(os.listdir(_monitoring_dir(archiver)))[0]
293+
_write_plain_report(archiver, name, ["not", "a", "dict"])
294+
output = cmd(archiver, "monitor", fork=True, exit_code=EXIT_ERROR)
295+
assert "monitoring report" in output
296+
assert "Traceback" not in output
297+
298+
285299
def test_monitor_key_export_is_deterministic(archivers, request):
286300
archiver = request.getfixturevalue(archivers)
287301
cmd(archiver, "repo-create", RK_ENCRYPTION)

src/borg/testsuite/monitoring_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,50 @@ def test_plaintext_report_refused_when_monitor_key_is_configured():
238238
monitor_key = mc.parse_monitor_key(mc.export_monitor_key(make_key()))
239239
with pytest.raises(low_level.IntegrityError):
240240
m.deserialize(monitor_key, repo_id, NAME, data)
241+
242+
243+
# --- validation of untrusted report contents -------------------------------------------
244+
245+
246+
def test_validate_report_accepts_a_real_report():
247+
report = sample_report("cc" * 32)
248+
assert m.validate_report(report) is report
249+
# the optional fields borg actually publishes are accepted, too
250+
report["hostname"], report["username"] = "somehost", "someuser"
251+
assert m.validate_report(report) is report
252+
253+
254+
@pytest.mark.parametrize(
255+
"body",
256+
[
257+
["not", "a", "dict"], # a non-object body would crash report.get()
258+
"a string",
259+
None,
260+
{}, # no fields at all
261+
{"command": "create", "status": "success", "rc": 0}, # no time
262+
{"command": "create", "time": "2026-06-17T11:59:58+00:00", "status": "success"}, # no rc
263+
{"command": "create", "time": 12345, "status": "success", "rc": 0}, # time not a string
264+
{"command": ["create"], "time": "2026-06-17T11:59:58+00:00", "status": "success", "rc": 0},
265+
# an unhashable value in a grouping field would crash the reader
266+
{
267+
"command": "create",
268+
"time": "2026-06-17T11:59:58+00:00",
269+
"status": "success",
270+
"rc": 0,
271+
"archive": ["unhashable"],
272+
},
273+
# an unknown status would count as "not an error", i.e. as a success
274+
{"command": "create", "time": "2026-06-17T11:59:58+00:00", "status": "all good", "rc": 0},
275+
],
276+
)
277+
def test_validate_report_rejects_unusable_bodies(body):
278+
with pytest.raises(ValueError):
279+
m.validate_report(body)
280+
281+
282+
def test_deserialize_rejects_unusable_plaintext_body():
283+
# what an unencrypted repo (or an injecting server) can serve must not reach the reader
284+
repo_id = os.urandom(32)
285+
data = bytes([m.FORMAT_VERSION, m.BODY_PLAIN]) + b'["not", "a", "dict"]'
286+
with pytest.raises(ValueError):
287+
m.deserialize(None, repo_id, NAME, data)

0 commit comments

Comments
 (0)