Summary
ui.Client.isAsking (daemon/ui/client.go) is a single bool field on the Client
struct, not scoped per-connection. main.go's acceptOrDeny() uses it as a simple
gate before calling uiClient.Ask():
// main.go, acceptOrDeny()
if uiClient.Connected() == false || uiClient.GetIsAsking() == true {
applyDefaultAction(packet, con)
log.Debug("UI is not running or busy, connected: %v, running: %v", uiClient.Connected(), uiClient.GetIsAsking())
return nil
}
uiClient.SetIsAsking(true)
defer uiClient.SetIsAsking(false)
...
r = uiClient.Ask(con)
Ask() uses a 120-second timeout (client.go):
ctx, cancel := context.WithTimeout(context.Background(), time.Second*120)
Effect: while any single connection's AskRule round-trip is in flight (which can
legitimately take up to 120 seconds if a human hasn't answered yet, or is answering a
different prompt), every other new connection that arrives during that window takes
the isAsking == true branch and gets DefaultAction applied immediately — silently,
with no AskRule ever sent to the UI, and no log output above Debug level (the
shipped default is LogLevel: 2 / IMPORTANT, which does not show this).
With the shipped default DefaultAction: allow, this means: during any single pending
decision, an unbounded number of unrelated new outbound connections from other
processes silently pass through with no user visibility, for up to 2 minutes at a time.
On a normal interactive desktop generating multiple new connections per second, this
window recurs continuously in practice, not as a rare edge case.
Why this matters
Firewall software's worst failure mode is "silently not filtering while appearing
healthy." This bug produces exactly that: every other signal a management UI can
observe (daemon connectivity, nftables/eBPF status, the daemon process being alive and
responsive) stays fully healthy throughout, because the daemon genuinely is healthy —
it's just serializing verdict requests on a single global flag instead of per
connection.
Reproduction
- Run opensnitchd with a UI/bridge connected but not actively/instantly answering
prompts (e.g. no human present, or a slow human).
- Trigger a connection to a genuinely novel destination so it needs an
AskRule
(isAsking becomes true, up to 120s).
- While that's pending, trigger a second connection to a different novel
destination from a different process.
- Observe: the second connection is not asked about at all. It resolves instantly
per DefaultAction. Only a Debug-level log line
(UI is not running or busy, connected: true, running: true) records it —
note the log format string's "running" label is actually GetIsAsking(), which
is also a bit confusing independent of the underlying bug.
Suggested fix direction
Replace the single global isAsking bool with either:
- a bounded worker pool / semaphore that serializes
Ask() calls without silently
dropping concurrent requests to DefaultAction (e.g. queue them, or run multiple
Ask() calls concurrently up to some cap), or
- at minimum, elevate the silent-default path to a
Warning/Important-level log
line so it's visible without manually raising LogLevel, since it represents a
real (if working-as-designed) firewall bypass event.
Happy to help test a patch — found this while building a third-party GUI on top of
opensnitchd and needed to understand exactly when interactive verdict requests can be
silently skipped.
Summary
ui.Client.isAsking(daemon/ui/client.go) is a singleboolfield on theClientstruct, not scoped per-connection.
main.go'sacceptOrDeny()uses it as a simplegate before calling
uiClient.Ask():Ask()uses a 120-second timeout (client.go):Effect: while any single connection's
AskRuleround-trip is in flight (which canlegitimately take up to 120 seconds if a human hasn't answered yet, or is answering a
different prompt), every other new connection that arrives during that window takes
the
isAsking == truebranch and getsDefaultActionapplied immediately — silently,with no
AskRuleever sent to the UI, and no log output aboveDebuglevel (theshipped default is
LogLevel: 2/IMPORTANT, which does not show this).With the shipped default
DefaultAction: allow, this means: during any single pendingdecision, an unbounded number of unrelated new outbound connections from other
processes silently pass through with no user visibility, for up to 2 minutes at a time.
On a normal interactive desktop generating multiple new connections per second, this
window recurs continuously in practice, not as a rare edge case.
Why this matters
Firewall software's worst failure mode is "silently not filtering while appearing
healthy." This bug produces exactly that: every other signal a management UI can
observe (daemon connectivity, nftables/eBPF status, the daemon process being alive and
responsive) stays fully healthy throughout, because the daemon genuinely is healthy —
it's just serializing verdict requests on a single global flag instead of per
connection.
Reproduction
prompts (e.g. no human present, or a slow human).
AskRule(
isAskingbecomestrue, up to 120s).destination from a different process.
per
DefaultAction. Only aDebug-level log line(
UI is not running or busy, connected: true, running: true) records it —note the log format string's "running" label is actually
GetIsAsking(), whichis also a bit confusing independent of the underlying bug.
Suggested fix direction
Replace the single global
isAsking boolwith either:Ask()calls without silentlydropping concurrent requests to
DefaultAction(e.g. queue them, or run multipleAsk()calls concurrently up to some cap), orWarning/Important-level logline so it's visible without manually raising
LogLevel, since it represents areal (if working-as-designed) firewall bypass event.
Happy to help test a patch — found this while building a third-party GUI on top of
opensnitchd and needed to understand exactly when interactive verdict requests can be
silently skipped.