Policy engine: decrypt cache keyed by id(packet) can give a new packet an older packet's sender and text #167

Open
opened 2026-10-27 09:36:01 +01:00 by adam · 1 comment
Owner

Hi all, I think I've found a bug in the policy engine. It's been letting spam through on my repeater even though a drop rule should catch it.

For context, I run SpamGuard (https://github.com/flackrat/openhop-spamguard), an add-on that writes spam drop rules into the policy engine, with an allow rule for known senders sitting above them.

The channel decrypt cache in the policy engine (_channel_decrypt_cache) is keyed on id(packet) and never gets cleared until the policy is reloaded. Python reuses ids once an object's been freed, so a new packet can end up with the sender and text from an older packet that happened to have the same id.

How I spotted it: I've got an allow rule for known senders, then a drop rule for anything with 98 in the path. Three copies of the same spam packet came in through different neighbours within a few seconds. Two got dropped by the 98 rule, but the third was forwarded with no drop reason. The spammer isn't in the known list, so the only way that copy got through is if it was read as someone else.

Here's a quick repro using the policy engine on its own:

import gc
from repeater.policy_engine import PolicyEngine

class Pkt:
    def __init__(s, sender, text, path):
        s.decrypted = {"group_text_data": {"sender": sender, "text": text}}
        s.path = path
    def get_payload_type(s): return 5
    def get_payload(s): return bytes(23)
    def get_path_hashes_hex(s): return s.path

pe = PolicyEngine({"enabled": True, "default_action": "allow",
    "objects": {"demo": {"known": ["AndyK", "SteNMP"]}},
    "rules": [
        {"id": 1, "if": {"all": [{"field": "channel_sender", "op": "in", "value": "@demo.known"}]}, "then": {"action": "allow"}},
        {"id": 2, "if": {"all": [{"field": "path_hashes", "op": "contains", "value": "98"}]}, "then": {"action": "drop"}}]})
ctx = {"payload_type": 5}

p = Pkt("AndyK", "evening all", ["FA", "53"]); pe.evaluate(p, ctx); del p; gc.collect()
leaks = 0
for _ in range(200):
    q = Pkt("RANDOM01", "spam", ["98", "71", "49"])
    leaks += pe.evaluate(q, ctx).action == "allow"
    k = Pkt("SteNMP", "hello", ["CF"]); pe.evaluate(k, ctx); del k, q; gc.collect()
print(leaks, "of 200 spam packets allowed")   # 192 here; a fresh engine drops every one

On my setup that lets 192 of the 200 spam packets through. A fresh engine drops every one.

The cache only needs to last for one evaluate() call, so clearing it at the start of evaluate() sorts it. With that one line the same test lets 0 through. It also stops the cache growing forever between reloads. I'll put a PR up against dev with the change.

Happy to test anything on my repeater.

Hi all, I think I've found a bug in the policy engine. It's been letting spam through on my repeater even though a drop rule should catch it. For context, I run SpamGuard (https://github.com/flackrat/openhop-spamguard), an add-on that writes spam drop rules into the policy engine, with an allow rule for known senders sitting above them. The channel decrypt cache in the policy engine (`_channel_decrypt_cache`) is keyed on `id(packet)` and never gets cleared until the policy is reloaded. Python reuses ids once an object's been freed, so a new packet can end up with the sender and text from an older packet that happened to have the same id. How I spotted it: I've got an allow rule for known senders, then a drop rule for anything with `98` in the path. Three copies of the same spam packet came in through different neighbours within a few seconds. Two got dropped by the `98` rule, but the third was forwarded with no drop reason. The spammer isn't in the known list, so the only way that copy got through is if it was read as someone else. Here's a quick repro using the policy engine on its own: ```python import gc from repeater.policy_engine import PolicyEngine class Pkt: def __init__(s, sender, text, path): s.decrypted = {"group_text_data": {"sender": sender, "text": text}} s.path = path def get_payload_type(s): return 5 def get_payload(s): return bytes(23) def get_path_hashes_hex(s): return s.path pe = PolicyEngine({"enabled": True, "default_action": "allow", "objects": {"demo": {"known": ["AndyK", "SteNMP"]}}, "rules": [ {"id": 1, "if": {"all": [{"field": "channel_sender", "op": "in", "value": "@demo.known"}]}, "then": {"action": "allow"}}, {"id": 2, "if": {"all": [{"field": "path_hashes", "op": "contains", "value": "98"}]}, "then": {"action": "drop"}}]}) ctx = {"payload_type": 5} p = Pkt("AndyK", "evening all", ["FA", "53"]); pe.evaluate(p, ctx); del p; gc.collect() leaks = 0 for _ in range(200): q = Pkt("RANDOM01", "spam", ["98", "71", "49"]) leaks += pe.evaluate(q, ctx).action == "allow" k = Pkt("SteNMP", "hello", ["CF"]); pe.evaluate(k, ctx); del k, q; gc.collect() print(leaks, "of 200 spam packets allowed") # 192 here; a fresh engine drops every one ``` On my setup that lets 192 of the 200 spam packets through. A fresh engine drops every one. The cache only needs to last for one `evaluate()` call, so clearing it at the start of `evaluate()` sorts it. With that one line the same test lets 0 through. It also stops the cache growing forever between reloads. I'll put a PR up against `dev` with the change. Happy to test anything on my repeater. <!-- github-sync-id:5795563219 -->
Author
Owner

Quick update in case it helps with triage. I re-ran the repro above against the latest dev today (a50e449) and it still lets 192 of 200 spam packets through. With the one-line change from #521 applied on top of a50e449 it lets none through, and the patch still applies cleanly.

I've also been through fresh installs of 1.1.4 and 1.1.5 on a Pi, a PC and in Docker this week. The bug shows up the same way on all of them, since it's in the policy engine itself rather than anything platform specific. Anyone running drop rules underneath an allow rule that matches on channel_sender can see spam slip through now and then until this is fixed.

Happy to rebase #521 or change the approach if you'd rather fix it another way, for example keying the cache on something stable or dropping it altogether.

Quick update in case it helps with triage. I re-ran the repro above against the latest `dev` today (a50e449) and it still lets 192 of 200 spam packets through. With the one-line change from #521 applied on top of a50e449 it lets none through, and the patch still applies cleanly. I've also been through fresh installs of 1.1.4 and 1.1.5 on a Pi, a PC and in Docker this week. The bug shows up the same way on all of them, since it's in the policy engine itself rather than anything platform specific. Anyone running drop rules underneath an allow rule that matches on `channel_sender` can see spam slip through now and then until this is fixed. Happy to rebase #521 or change the approach if you'd rather fix it another way, for example keying the cache on something stable or dropping it altogether. <!-- github-comment-id:6145826975 -->
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: adam/pyMC_Repeater#167