Skip to content

TransferService: harden OBEX incoming-file handling (7 audit findings) - #3320

Open
geraldo-netto wants to merge 44 commits into
blueman-project:mainfrom
geraldo-netto:fix/transfer-service-hardening
Open

TransferService: harden OBEX incoming-file handling (7 audit findings)#3320
geraldo-netto wants to merge 44 commits into
blueman-project:mainfrom
geraldo-netto:fix/transfer-service-hardening

Conversation

@geraldo-netto

@geraldo-netto geraldo-netto commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens blueman/plugins/applet/TransferService.py — the OBEX incoming-file
(OPP) plugin. Started from 7 TODO.md rescan findings scoped to this file; a
follow-up review surfaced 4 more. 11 findings total, one commit per finding,
each with tests. Module statement coverage is now 100% (50 unit + fuzz cases).

No behavioral change for the happy path; every change either closes a security/
correctness gap or makes existing intent actually hold.

Findings

sec-1 — Pango markup injection in the invalid-dir notification (security)

  • What: the "incoming files directory does not exist" notification interpolated the user-configured shared-path and the fallback path into <b>%s</b> markup without escaping.
  • Why it matters: a path containing markup/entities (<, &, quotes) could alter the notification body; daemons that render body markup could misinterpret it.
  • Fix: html.escape both interpolated paths before formatting. Regression tests with <b>, &, and quote characters.

i18n-3 — Untranslated action label (i18n)

  • What: the notification's "Reset to default" action used a bare English string.
  • Why: it never localized.
  • Fix: wrap in _() (file already in po/POTFILES.in, so it's extracted). Test asserts the label routes through gettext.

rel-10 — Wrong device revoked by removal timeout (reliability)

  • What: the 60s timeout revoking a one-shot OPP authorization read self._pending_transfer['address'] when it fired, not when scheduled.
  • Why: an overlapping push or cleared state could revoke the wrong device or trip the assert.
  • Fix: capture the accepted address as a closure default argument bound at schedule time; _allowed_devices is now a set revoked with discard so a double-fire can't raise. Tests cover correct revoke, idempotency, and overlap.

sm-8 — Single pending slot clobbered by overlapping pushes (scalability/concurrency)

  • What: one _pending_transfer slot held the in-flight authorization, so a second push arriving before the user answered overwrote the state the first notification's callback read.
  • Why: the wrong file could be accepted, or the action could fail.
  • Fix: key pending records by transfer_path in a dict; bind each notification callback to its own immutable record (default-arg capture); the callback pops only its own record. Test drives two overlapping requests and asserts independence.

data-1 — Same-second filename collision can overwrite (data integrity)

  • What: collision handling prefixed only a second-resolution timestamp, then moved without rechecking the timestamped destination.
  • Why: two same-named transfers completing in the same second could collide and overwrite/fail.
  • Fix: new reserve_destination walks name, timestamp_name, timestamp_1_name, … and reserves the first free candidate with an O_EXCL create, then moves the source onto the reservation; the placeholder is cleaned up if the move fails. Same-second and fuzz tests over odd filenames (spaces, unicode, dotfiles, long names).

dec-1 — Agent coupled to the whole applet (decoupling/SOLID)

  • What: the OBEX Agent took the entire BluemanApplet only to reach parent.Manager.get_adapter/find_device for the device name + trust state.
  • Why: unnecessary coupling to the applet object graph; hard to test.
  • Fix: inject a DeviceResolver callable (source, address) -> (name, trusted); the plugin supplies one bound to its parent.Manager, and the Agent depends only on that. Drops the BluemanApplet import. The resolver's raise→untrusted-fallback contract is documented on the type alias. Authorization is now testable with a 1-line stub.

test-3 — No focused tests for authorization/completion (test coverage)

  • What: the authorization and completion paths had no targeted tests.
  • Fix: unit + fuzz tests for overlapping requests, allowed-device expiry, filename collisions, failed moves, counters, session summaries, auto-accept, and share-path resolution.

A — Transfer counters never reset (reliability) [follow-up]

  • What: _on_session_removed reported _silent_transfers/_normal_transfers but never zeroed them.
  • Why: every session after the first double-counted background transfers and reported wrong totals.
  • Fix: reset both counters once the summary has consumed them. Regression test.

B — Class-level mutable _handlerids (correctness) [follow-up]

  • What: _handlerids = [] was a class attribute shared by all instances; _on_dbus_name_appeared appended to the class list before any instance copy existed.
  • Why: latent cross-instance state bleed.
  • Fix: initialize self._handlerids = [] per-instance in on_load. Test asserts the instance list is distinct from the class attribute.

C — Redundant elif not success (code clarity) [follow-up]

  • What/Fix: success is a bool; if success / elif not success collapsed to if/else.

D — Path(None) crash when XDG has no download dir (robustness) [follow-up]

  • What: _make_share_path built Path(GLib.get_user_special_dir(DOWNLOAD)) unconditionally; when XDG returns no dir this raised TypeError on Path(None) before the existing ~ fallback could run — making that fallback dead code.
  • Why: crash on systems without an XDG download dir.
  • Fix: build default_path only when XDG yields a value, so the ~ fallback applies. Test patches XDG to None.

Unrelated CI fix — flaky test_resolver_changed

Adding this PR's tests pushed the suite to 392 tests and shifted timing enough to expose a pre-existing flake in test/main/test_dns_server_provider.py (not part of the OPP hardening). CI failed on it for test (3.12) and test (3.14):

FAIL: test_resolver_changed
AssertionError: expected call not found.
Expected: mock(<DNSServerProvider ...>)
  Actual: not called.
  • Root cause: _test_changed truncated the watched resolver file, then drained only already-queued GLib events (while context.pending(): context.iteration()). The Gio.FileMonitor CHANGED event is delivered asynchronously — it was frequently not queued yet when the drain ran, so the changed signal looked like it never fired. The systemd-resolved variant emits synchronously over D-Bus, so only the file-monitor variant raced.
  • Fix: block the main loop until the changed signal actually fires (context.iteration(may_block=True)), bounded by a 5s timeout backstop so a genuine miss still fails fast instead of hanging. Deterministic for both the file-monitor and D-Bus variants.

Kept as a standalone commit so the OPP hardening history stays per-finding.

Testing

  • 50 unit/fuzz tests, all passing
  • Module statement coverage 51% → 100%
  • pycodestyle + pyflakes clean; mypy --strict clean on the module
  • Test seam: BLUEMAN_SOURCE=1 python3 -m unittest test.plugins.applet.test_transfer_service

🤖 Generated with Claude Code

geraldo-netto and others added 30 commits June 2, 2026 11:48
Add AGENTS.md (canonical agent behavior rules) and CLAUDE.md pointer.
Populate TODO.md with project-wide rescan findings, one table per
AGENTS.md review category (security, STRIDE, data governance, watchdog,
state machine, composition, dependency, extensibility, legacy,
configuration, platform, data structure, vectorization, robustness,
ui/ux, documentation, plus existing performance/concurrency/SOLID tables).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wire-1 (DHCP renew routed to wrong proxy) and wire-2 (AppletDhcpClientService
never instantiated) fixed on branch fix/wire-dhcp-proxy. wire-3 recorded as a
deliberately-rejected false positive: AppletStatusIconService is a required
signal-only proxy in Tray.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SpeedCalc log-bounding + zero-elapsed guard shipped on branch
perf/small-optimizations. Park the ManagerProgressbar cleanup and GtkAnimation
timer items: both are GTK-app-bound / architectural, not low-risk and not
unit-testable headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NetConf iptables arg handling + IPv4 validation, Rfcomm ps-output parsing, and
PPPConnection APN validation shipped on branch security/input-validation.
Removes sec-1..sec-4 and the duplicate stride-3 row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DhcpClient client-state guards + poll fix, mechanism Network handler-key
validation, NetConf cleanup next() default, and Rfcomm open error handling
shipped on branch reliability/crash-guards. Removes rel-1, rel-4, rel-6,
rel-8, rel-10 and the duplicate stride-2 row. Remaining rel items are the
PPPConnection/Services paths that need a live GLib loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the flat category-name list with a deduplicated Category
definitions subsection: each review category carries a concise
definition and, where relevant, the framework to cite per finding
(STRIDE, OWASP ASVS, Laws of UX). Scoped to this project's domain
(D-Bus, polkit, network plugins, GTK, gettext, meson/autotools CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add 33 findings across previously-empty review categories: dependability,
distributed systems, time & scheduling correctness, memory and cpu
management, system design, CLI / option integrity, product engineering,
design thinking, and test / fuzz coverage; plus rel-13 (NetConf clean_up
StopIteration). Deduped against existing findings; dropped false/already-
fixed candidates (DhcpClient timeout no-op, blueman-report nonexistent,
overlaps with perf-/arch-).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove findings whose fix exists on a dedicated branch or already landed
on main, to stop double-tracking:
- perf-2/6/7, scale-4, dup-3  -> fix/manager-device-list-perf (GetAll, single
  power-level timer, cached uuids, batched set)
- conc-1/2, sysd-1            -> fix/bluez-base-concurrency-guards (Cancellable
  plumbing, WeakValueDictionary instance cache + destroy cleanup)
- rel-2/3/5/7, sm-1           -> fix/ppp-crash-guards (init attrs, guarded
  cleanup, source removal)
- rel-13                      -> reliability/crash-guards (next(...,None))
- ds-1                        -> already on main (SpeedCalc deque, blueman-project#3289)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each finding's description starts with file:line, so sorting every TODO.md
table by description clusters same-file items for batch fixing. Add the rule
to AGENTS.md and sort all existing tables accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
comp-1 (InstanceRegistry extraction), perf-4 (cache-first Base.get), cache-1
(explicit freshness/stale), obs-13 (log cached fallback) are implemented on the
perf/bluez-base-property-cache branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rob-4 (track/cancel discovery progress timer), perf-3 (single-pass clear), and
vec-3 (release row references before clearing) are implemented on the
perf/devicelist-clear-discovery branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment only the non-obvious (why/constraints/edge cases); delete redundant
comments rather than write them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exact powers of 1024 fell through to the GB branch because both band
edges used strict `<`, so format_bytes(1024) returned (9.5e-07, "GB")
instead of (1.0, "KB"); 1 MiB and 1 GiB were mislabelled the same way.

Drop the lower-bound comparison and rely on the cascading upper bounds
so each boundary lands in its own unit. Add test/test_functions.py
covering the 1024/1048576/1073741824 boundaries plus zero, sub-KB,
mid-band, and a huge value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
adapter_path_to_name parses a D-Bus object path with a greedy
`re.search(r".*(hci[0-9]*)", path)` and had no tests. Add cases pinning
the current contract: normal paths, None/empty -> None, no-hci -> None,
case sensitivity, trailing device segments, zero-digit "hci", greedy
last-occurrence selection, and embedded matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zz-5, data-3)

parse_os_release was nested inside log_system_info and split each line
with `line.split("=")`, so a valid quoted value containing "=" (e.g.
PRETTY_NAME="Name=Variant") raised ValueError and was dropped from the
logged system info.

Promote it to module scope, parse with str.partition("=") so only the
first "=" separates key from value, and skip blank lines explicitly.
Add tests for basic keys, a value containing "=", unquoted values,
comment/blank lines, lines without "=", and a missing file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
have() hand-rolled a PATH scan with a hardcoded ":/sbin:/usr/sbin"
suffix and checked os.access(path, os.EX_OK) -- os.EX_OK is 0, i.e.
F_OK, so it only confirmed existence, not executability.

Delegate the lookup to shutil.which, which honours the executable bit,
and append the sbin directories to the search path only when they are
not already present. Add tests covering found/not-found, sbin-dir
augmentation, and de-duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_logger unconditionally constructed SysLogHandler(address="/dev/log"),
which raises on platforms and minimal containers without that socket,
taking down the whole process at logger setup.

Guard the handler construction and, on OSError, log a warning and keep
the basicConfig stderr handler instead. Add tests for the available,
unavailable, and syslog-disabled paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--loglevel had no choices and no help, so a typo like `--loglevel
verbose` silently coerced to WARNING across all entry points, leaving
users with quieter logs than intended and no error.

Add case-insensitive choices (debug/info/warning/error/critical) via
type=str.lower plus help text, so argparse rejects unknown values
clearly. Existing consumers compare args.LEVEL.upper(), which is
unaffected. Add tests for default, lowercasing, rejection, help/choices
metadata, the syslog flag, and disabling loglevel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "applet needs to be running" and "Failed to enable bluetooth"
messages went to stdout via print(), bypassing the logging
configuration and leaving no record in syslog/journald.

Route both through logging.error (with exc_info on the DBusProxyFailed
path, replacing the redundant logging.exception). Add tests for the
missing-applet exit path and the no-PowerManager early return.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
set_proc_title unconditionally loaded libc.so.6 and called
prctl(PR_SET_NAME), both Linux/glibc specific. On other platforms the
LoadLibrary or prctl lookup raises and crashes process startup.

Return early as a no-op on non-Linux, wrap the libc/prctl access in
try/except returning -1 on failure, and document the behaviour in the
docstring. Add tests for the non-Linux no-op, the Linux prctl path, and
the libc-unavailable failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
launch() accepted only a full command-line string, so callers embedded
options directly in cmd (e.g. Notes.py built
"blueman-sendto --delete --device={addr}"), making argument boundaries
depend on GLib command-line parsing rather than an argv contract.

Add an optional args iterable: when provided, the program token and each
argument are shell-quoted individually via GLib.shell_quote, so spaces,
quotes, and shell metacharacters can never cross argument boundaries.
The legacy string form still works when args is omitted (deprecated).
Migrate the Notes.py send-note call site to the argv form.

Add tests for the legacy form, argv quoting, metacharacter
neutralization, the launch result, and path-to-GFile conversion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doc-1 framed these as dead helpers to "document or remove", but they are
live: every entry point in apps/*.in imports and calls them (the audit
missed the .in sources). Document them instead of removing, noting their
role and the syslog fallback / shared CLI surface. set_proc_title was
already documented alongside the leg-5 platform guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
set_proc_title, create_logger, and create_parser were flagged as unused
with "no production callers", but the audit only scanned *.py and missed
apps/*.in: all three are imported and called by every blueman binary.
Deleting them would break startup of every executable.

Move them from "unused functions" to "Audit picks deliberately rejected"
with the evidence and pointers to the real fixes made instead
(cli-1, plat-8, leg-5, doc-1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both deprecated APIs (Gtk.Dialog.run and Gtk.ImageMenuItem) still
function under GTK3. leg-1 is a synchronous startup gate whose async
rewrite ripples into every entry point and needs a live main loop;
leg-2's create_menuitem is a 20-call-site chokepoint whose replacement
changes menu-item child structure. Neither can reach genuine coverage
headless or be validated without running the GUI, matching the existing
parked-item rationale (perf-12/perf-14). Park for the GTK4 migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
geraldo-netto and others added 14 commits June 19, 2026 20:29
Remove the 14 NetConf.py findings addressed on the fix/netconf-hardening
branch (PR to upstream): obs-2, obs-5, plat-3, plat-9, plat-4, cfg-3,
depend-2, depend-1, mem-3, sm-7, wd-7, dist-2, dist-1, dist-4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the eight Sendto.py findings addressed on the fix/sendto-hardening
branch (PR to upstream): time-2, rob-7, vec-1, dup-7, obs-8, prodeng-1,
prodeng-2, ux-1. Park leg-3/ux-6 (async-dialog conversion) with rationale
alongside the other parked GTK deprecation items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ec-1)

The fallback "incoming files directory does not exist" notification
interpolated the user-configured `shared-path` and the default fallback
path into Pango `<b>%s</b>` markup without escaping. A path containing
markup or entities could alter the notification body, and daemons that
render body markup could misinterpret it.

Escape both interpolated paths with `html.escape` before formatting.
Add regression tests covering `<`/`>`, `&`, and quote characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reset action on the invalid-share-path notification used a bare
English string, so it never localized. Wrap it in `_()`; the file is
already listed in `po/POTFILES.in`, so the string is now extracted.

Add a test that the label is routed through gettext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 60s timeout that revokes a one-shot OPP authorization read
`self._pending_transfer['address']` when it fired, not when it was
scheduled. An overlapping push or cleared pending state could revoke the
wrong device or trip the assertion.

Capture the accepted address as a default argument bound at schedule
time and revoke it idempotently. Switch `_allowed_devices` to a set so
removal is `discard`-style and a double fire cannot raise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single `_pending_transfer` slot held the in-flight authorization, so a
second incoming push arriving before the user answered the first
overwrote the state the first notification's callback later read — the
wrong file could be accepted or the action could fail.

Key pending records by `transfer_path` in a dict and bind each
notification callback to its own immutable record (captured as a default
argument). The callback pops only its own record on accept/reject, so
overlapping requests stay independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collision handling prefixed only a second-resolution timestamp and then
moved without rechecking the timestamped destination, so two same-named
transfers completing in the same second could collide and
overwrite/fail.

Add `reserve_destination`, which walks `name`, `timestamp_name`,
`timestamp_1_name`, ... and reserves the first free candidate with an
O_EXCL create, then move the source onto the reservation. Clean up the
reserved placeholder if the move fails. Covered by same-second and fuzz
tests over odd filenames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Agent took the whole `BluemanApplet` only to reach
`parent.Manager.get_adapter`/`find_device` for the pushing device's name
and trust state, coupling the D-Bus agent to the applet object graph.

Introduce a `DeviceResolver` callable `(source, address) -> (name,
trusted)`; the plugin supplies one bound to its `parent.Manager` and the
Agent depends only on that. Drops the `BluemanApplet` import and makes
authorization unit-testable with a plain resolver stub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add focused unit tests for the incoming-transfer paths that previously
had none: overlapping authorization requests, allowed-device expiry,
filename collisions, failed final moves, transfer counters, session
summaries, auto-accept, agent control, and share-path resolution. They
exercise the helpers extracted for data-1/sm-8/dec-1 with mocked
Transfer, Session, and Notification.

Module statement coverage is now 83% (from 51%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove AGENTS.md, CLAUDE.md, and TODO.md; they are project-rescan-todo
scaffolding, not part of the TransferService changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document that a resolver may raise and that callers treat a raised
resolver as an untrusted/unknown device — a behavioral contract the type
alias alone does not convey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up review of TransferService.py surfaced four issues beyond the
original rescan items:

A. reliability — _on_session_removed reported the silent/normal transfer
   counts but never reset them, so every session after the first
   double-counted background transfers. Reset both counters once the
   summary has consumed them.

B. correctness — `_handlerids` was a class-level mutable list shared by
   every instance; _on_dbus_name_appeared mutated the class list before
   any instance copy existed. Initialize it per-instance in on_load.

D. robustness — _make_share_path built `Path(GLib.get_user_special_dir(
   DOWNLOAD))` unconditionally; when XDG returns no download dir this
   raised TypeError on Path(None) before the `~` fallback could run.
   Build default_path only when XDG yields a value so the existing
   fallback applies.

Also collapse a redundant `elif not success` to `else` (C).

Tests bring module coverage to 100% (50 cases), including agent
lifecycle, D-Bus name appeared/vanished, on_load/on_unload, and every
_make_share_path branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace logging.error(..., exc_info=True) with logging.exception(...) at
the three exception handlers (applet proxy failure, set_proc_title,
socket creation). Equivalent output; clearer intent and satisfies the
static-analysis rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_resolver_changed truncated the resolver file then drained only
already-queued GLib events. The Gio.FileMonitor CHANGED event is
delivered asynchronously and was often not queued yet, so the assertion
saw the changed signal as never emitted. Block the main loop until the
signal fires, bounded by a 5s timeout backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant