Skip to content

security(ansible): an unresolved network_subnet silently widens five firewall rules to any source #17642

Open

Description

@mrveiss

An unresolved network_subnet means "allow from anywhere", silently, in five firewall rules

Settled from the installed module source, not by applying a rule. Nothing was
modified and no firewall was touched.

This came out of scoping #17500. It is a separate defect: it spans two roles and
is not specific to the cache.

The mechanism

community.general 9.5.2, plugins/modules/ufw.py:405:

def execute(cmd, ignore_error=False):
    cmd = ' '.join(map(itemgetter(-1), filter(itemgetter(0), cmd)))

and the fragment that carries the source restriction, at :568-571:

for (key, template) in [('from_ip', "from %s"), ('from_port', "port %s"), ...]:
    value = params[key]
    cmd.append([value, template % (value)])

filter(itemgetter(0), cmd) keeps a fragment only when its value is truthy.
So when from_ip is the empty string, the pair is ['', 'from '], the filter
drops it, and the from clause is not emitted at all. The command becomes

ufw allow port <port> proto tcp comment '<comment>'

which is valid, returns rc 0, and — because ufw applies no source restriction
when none is given — allows from anywhere. No error, no warning, and the task
reports its normal changed/ok.

Note the module does declare from_ip=dict(type='str', default='any', ...) at
:354. That default is a red herring here: it only applies when the parameter is
omitted, and these tasks pass it explicitly. The widening comes from the drop, not
the default. Either way the destination is the same — anywhere.

Every definition of the variable already means "anywhere" when it does not resolve

Eight definitions, two fallback families, one outcome:

Fallback Where
or '' inventory/hosts.yml:14 · roles/redis/defaults/main.yml:7 · roles/browser/defaults/main.yml:30 · roles/frontend/defaults/main.yml:7
or '0.0.0.0/0' inventory/group_vars/infrastructure.yml:30 · inventory/hosts-router.yml:16 · inventory/production.yml:21 · inventory/slm-nodes.yml:29

The 0.0.0.0/0 family is at least honest — it says "the whole internet" in the
repo, where a reader can see it. The '' family arrives at the identical rule
without saying anything.

And the file that calls itself canonical is in the second group.
group_vars/infrastructure.yml:27-30 reads:

# Canonical network configuration - referenced by all group_vars.
# ... every install detects its own subnet automatically.
network_subnet: "{{ ... or '0.0.0.0/0' }}"

"Detects its own subnet automatically" is true when detection works. Its
unresolved state is the entire internet.

The five rules that consume it, none of them guarded

Rule Location
cache port roles/redis/tasks/main.yml:180
cache inspector port roles/redis/tasks/main.yml:191
browser rules ×3 roles/browser/tasks/main.yml:347, :361, :374

No when: on any of them, and nothing asserts the variable is non-empty before it
is used as a source restriction. roles/frontend/defaults/main.yml:7 defines the
variable too but I found no from_ip use site in that role.

Two from_ip sites are not affected and are worth naming so a fix does not
churn them: roles/common/tasks/main.yml:145 passes a literal loopback address,
and :161 uses item.src | default('any') — an explicit, deliberate "any".

Latent, not currently active — stated precisely

I am not claiming the deployment is open right now. #17635's own read-only
measurement records the cache as allowed from a routable /24, which means
network_subnet did resolve on that node. So this is a reachable latent path: a
host where neither the environment variable nor the install-time secrets file
resolves gets wide-open rules, and nothing reports that it happened. The rules
look identical in the playbook output either way.

Why it got this way — and why the earlier fixes are the cause

This variable has been fixed twice for the opposite symptom:

Both were closed as completed, and both were fixed by making the variable
resolve — adding the fallbacks above. That removed a loud failure and
installed a silent widening in its place. Nobody asked what the fallback means
when the consumer is a firewall source restriction, because the bug being fixed
was "the play stops", not "the rule is wrong".

That is the shape #13264 tracks: a migration that made a config default silently
wrong, fixed one site at a time.

What the fix has to do

One fix for the class, not five patches:

  1. Fail loudly instead of widening. A firewall source restriction that cannot
    be resolved must stop the play, not fall back. That means dropping every
    or '' / or '0.0.0.0/0' fallback on this variable and replacing it with an
    assertion at the point of use, or a mandatory filter at the definition.
  2. If a genuinely-open rule is ever intended, it says so explicitly at the task
    — the way roles/common/tasks/main.yml:161 already does with
    default('any') — never by a variable that failed to resolve.
  3. Evidence must be a refusal: a play run with the variable unresolved stops
    with a named error. "The rules looked right" is exactly the observation that
    holds today while the defect is present.

Worth checking in the same pass: three further from_ip sites derive the source
from a URL via regex_replace (roles/redis/tasks/redis_exporter.yml:92,
roles/slm_agent/tasks/main.yml:420, roles/slm_agent/tasks/node_exporter.yml:183).
Those have non-empty defaults, and on a non-matching input regex_replace returns
the subject unchanged, so they would hand ufw an invalid address and fail —
the better outcome. I did not verify that end to end and am not claiming it; it
just means they are likely not part of this defect.

Refs #17500, #17572, #17635, #3301, #7162, #13264

Activity

  1. added this to the v0.9.0 milestone on Sep 27, 2026
  2. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Chunking triage — a proposal, not an assignment

    Nothing was relabelled, moved or closed by this pass.

  3. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Chunk triage — v0.9.2 node plane and deploy. Read-only at origin/main 7adaca8c29; nothing labelled or moved.

    Premise verified, and it is worse in the specific way that makes it easy to miss: network_subnet is not undefined. It is defined as an env lookup with no final default.

    autobot-slm-backend/ansible/inventory/group_vars/infrastructure.yml:30 (and the same expression in hosts.yml:14, hosts-router.yml:16, production.yml:21, slm-nodes.yml:29):

    network_subnet: "{{ lookup('env', 'NETWORK_SUBNET') or lookup('pipe', 'grep -oP \"NETWORK_SUBNET=\\K.*\" /etc/autobot/slm-secrets.env') }}"

    Two fallbacks and no third. Compare the line four below it — dns_nameservers ends or '8.8.8.8', a real default. So when the env var is unset and the grep finds nothing, this yields the empty string rather than failing.

    Two consequences worth stating separately:

    1. The lookup('pipe', …) runs on the controller, not the node. Any controller without /etc/autobot/slm-secrets.env — a dev machine, a CI runner, a freshly imaged manager — silently produces empty. That is the same controller-versus-target confusion as the code_source class (CRITICAL(deploy): backend provisioning aborts on every non-manager host — a task executes a script out of code_source, which exists only on the controller (rc=127) #17242/deploy: five more tasks read code_source on hosts that do not have it — three are in the builtin updater (#17242 class) #17243), in a different guise.
    2. It reads as defined. A grep for network_subnet: finds five definitions, so the obvious check says the variable exists. Only reading the expression shows that "exists" and "resolves" are different questions.
    • Scale: the name is referenced across 21 files in ansible/ — six in aiml.yml, eight in backend.yml, seven in browser.yml, plus database/frontend/slm group_vars, bootstrap-node.yml, and the redis/browser/xrdp roles. The five ufw rules this issue names are the exposure; the rest is blast radius if the value is wrong rather than empty.
    • Pre-filters: the PR hit (PR#15389) is a false positive — it matches the digits, not the issue. No merged commit references it. Untouched.
    • This issue has no acceptance criteria. For a priority: critical that is the first gap to close, and the shape is not in doubt: fail closed when the value is empty (an assert in the role or a | mandatory), give the lookup a final default only if one is genuinely safe (it is not — there is no safe default subnet), and add a guard that no ufw rule's src can be a bare unvalidated variable.
    • Host vs code: all code. Settled from the installed module source, no firewall touched, and the fix is repository-side. Stays in this chunk.
  4. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Proposed acceptance criteria — a proposal for the owner to accept, amend or reject, not a ruling. Read-only; nothing changed.

    This issue is priority: critical with no acceptance criteria, so there is nothing for a closer to tick and nothing for an implementer to aim at. Below is what I would write, with the reasoning attached so each one can be argued with separately.

    The constraint that shapes all of them

    There is no safe default subnet. dns_nameservers four lines below can end or '8.8.8.8' because a wrong DNS server is a broken lookup; a wrong — or empty — src on a ufw rule is an open port. So the fix cannot be "add a default"; it has to be "refuse to proceed". That is why the criteria below are fail-closed rather than fall-back.

    Proposed

    • An empty or unset network_subnet fails the play, before any ufw task runs. An assert in the role (or | mandatory on the lookup) whose message names the variable, the inventory file that defines it, and NETWORK_SUBNET//etc/autobot/slm-secrets.env as the two sources — so the operator reads what to set, not that a task failed.
    • No ufw rule's src may be a bare unvalidated variable. A guard over autobot-slm-backend/ansible/ fails when a ufw: task's src is a template expression that has not passed the validation above. This is the criterion that stops the next instance, and it is the reason the others are not enough on their own.
    • The empty case is proved, not reasoned. A test that renders the five rules with network_subnet empty and asserts the play fails rather than emitting a rule without a from clause. Per #15826's remedy: a planted empty value must be caught, and a valid subnet must pass — both directions, or the guard is untested.
    • The controller-side lookup is stated as such. lookup('pipe', 'grep … /etc/autobot/slm-secrets.env') runs on the controller, not the target; the inventory comment should say so, because "detected at install time" reads as per-node detection and is not. A dev machine, a CI runner or a freshly imaged manager has no such file.
    • The value is validated as a CIDR, not merely as non-empty. NETWORK_SUBNET=yes is non-empty and is not a subnet; Acquire-style truthiness is what produced this class in the first place.
    • The other 21 referencing sites are triaged, separating the five ufw rules (where empty means allow from anywhere) from the rest (where empty means a broken bind or a wrong peer). Both matter; only the first is a security exposure, and a single fix that treats them alike will either over-block or under-validate.

    Deliberately not proposed

    No host verification criterion. Everything above is decidable in the repository, and the issue was settled from the installed module source without touching a firewall — adding a "verified on a live node" criterion would move a closable critical into v0.9-host for no gain.

    No baseline or grandfather list. Five rules is small enough to fix outright; a shrink-only baseline here would record an exposure rather than remove it.

    One note on the class

    This is the third instance today of the same confusion in a different guise — a controller-side value assumed to exist target-side (#17242's code_source execution, #17331's baked rewrite path, this). The first two were rc=127 and a missing constraints file; this one is a firewall rule that silently widens. The failure gets quieter as the value gets more important, which is an argument for the guard in criterion 2 over any amount of care at the five call sites.

  5. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    B5 spec — third in the batch, the long pole, and it carries the guard the other two reuse

    Batch B5. Full spec at ~/b5-spec-2026-09-28.md. The proposed acceptance criteria are in my earlier comment; this is the build spec.

    The constraint that shapes every change: there is no safe default subnet. A wrong DNS server is a broken lookup; an empty src is an open port. So everything here is fail-closed, never fall-back — which is why dns_nameservers can end or '8.8.8.8' four lines below and network_subnet cannot.

    Changes, in order: (1) an assert/| mandatory before any ufw task, naming network_subnet, NETWORK_SUBNET and /etc/autobot/slm-secrets.env so the operator reads what to set; (2) validate as a CIDR, not as non-empty — NETWORK_SUBNET=yes is non-empty and is not a subnet; (3) state in the inventory comment that lookup('pipe', …) runs on the controller — "detected at install time" currently reads as per-node detection and is not.

    Test file: repo_tests/ansible_ufw_src_is_validated_17642_test.py (new).
    Assertion: no ufw: task's src is an unvalidated template expression; a rendered rule with an empty src fails the play rather than emitting a rule with no from clause.
    Mutation: render the five rules with network_subnet empty → the play must fail. Negative control: a valid CIDR must pass. Plus a reach floor — finding no ufw tasks at all must fail, not pass by matching nothing.

    One scale note for the reviewer: the name is referenced across 21 files in ansible/. The five ufw rules are the exposure; the rest is blast radius if the value is wrong rather than empty. Triage those separately — only the first is a security finding.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions