keys(), items(), values() each acquired self.lock then returned a live
dict view (dict_keys, dict_items, dict_values). The lock was released
on return, leaving callers with an unsynchronised view that races with
concurrent tx()/rx()/remove() calls on other threads.
Wrap each return in list() so a snapshot is taken while the lock is held.
Tests added (tests/packets/test_tracker.py):
- test_keys_returns_snapshot_not_view: asserts isinstance list and that
a subsequent mutation does not appear in the returned value
- test_items_returns_snapshot_not_view: same for items()
- test_values_returns_snapshot_not_view: same for values()
Closes#242
factory() was calling globals()[raw['_type']] on a value read from a
persisted JSON file on disk without validation, allowing an attacker who
can write to ~/.config/aprsd/ to reference arbitrary names in the module
global namespace.
Add an allowlist (_known_packet_type_names) derived lazily from
TYPE_LOOKUP. Any _type value not in the set raises ValueError before
globals() is ever called.
Tests added (tests/packets/test_packet.py):
- test_factory_known_type_roundtrip: valid known _type still deserialises
- test_factory_unknown_type_raises: module-global name ('os') is rejected
- test_factory_arbitrary_string_raises: arbitrary strings are rejected
- test_factory_empty_type_raises: empty string is rejected
- test_factory_allowlist_covers_all_type_lookup_classes: allowlist stays
in sync with TYPE_LOOKUP automatically
Closes#239
stats() had a bare 'return self.data' on the first line that:
- returned the raw internal dict without holding self.lock (race condition)
- made the locked loop below unreachable dead code
- returned the wrong shape (callers expect age/old/packet/last keys, not
the raw was_old_before_update internal key)
Remove the early return so the existing with self.lock: loop executes.
Tests added (tests/packets/test_watch_list.py):
- test_stats_empty: empty watch list returns {}
- test_stats_returns_enriched_shape: verifies the four expected keys are
present and the raw-internal 'was_old_before_update' key is absent
- test_stats_not_raw_internal_dict: returned dict must not be wl.data itself
- setUp/tearDown: reset class-level data and initialized to prevent leakage
Closes#237
DupePacketFilter.filter() was checking packet.processed (the newly
arrived duplicate) instead of found.processed (the previously stored
packet). Since all freshly decoded packets have processed=False by
default, the dupe detection branch was never reachable — every
duplicate message was passed through and re-processed.
This caused KM6LYW's retransmit of msg:9028 (via a different
digipeater path 63s after first receipt) to trigger NearestPlugin
a second time, sending 4 reply packets instead of 2 and resetting
the AckPacket retry counter back to (1 of 3).
Fix: check found.processed instead of packet.processed.
Update existing tests to reflect the correct variable under test.
Add regression test test_filter_aprs_retransmit_via_different_digi
that reproduces the exact production scenario.