Skip to content

security(redis): the rendered config is open by default — wildcard bind, protected-mode off, and a requirepass that is silently omitted #17551

Description

@mrveiss

The exposure

autobot-slm-backend/ansible/roles/redis/templates/redis-stack.conf.j2 renders a
Redis Stack config with these three properties at once:

line directive effect
:5 bind 0.0.0.0 listens on every interface
:11 protected-mode no disables the safety net that otherwise refuses non-loopback clients when no password is set
:79-81 {% if redis_password is defined and redis_password %}requirepass {{ redis_password }}{% endif %} emits no requirepass when the password is empty

And the password is empty, by construction rather than by accident:

# roles/redis/vars/main.yml:22
redis_password: "{{ vault_redis_password | default('') }}"

vault_redis_password is never defined anywhere in the repository. A
repository-wide search for an assignment (^\s*vault_redis_password\s*:) returns
nothing; every occurrence is a consumer, three of the four through
| default(''). So redis_password resolves to the empty string, the Jinja
guard is false, and the rendered config contains no requirepass line.

The three together describe an unauthenticated Redis Stack listening on all
interfaces with the protocol-level guard switched off.

Why it has not bitten yet, and what changes that

The rendered template has not been the config the daemon reads. On main,
ansible/templates/systemd/redis-stack-server.service.j2:30 starts the daemon
with a different path from the one the role renders to, so the rendered
content has never taken effect through that path. That mismatch is the subject of
#17434.

Fixing the mismatch is correct and should happen. The consequence worth deciding
deliberately is that it makes this configuration live: the same restart that
finally hands the daemon its rendered config is the restart that applies
bind 0.0.0.0 + protected-mode no + no password for the first time.

What is verified here: the three directives, the empty-password chain, and
that the two paths differ on main. All from the repository.

What is not: whether any running node is exposed today by some other route.
That needs a readback from the host (CONFIG GET bind, protected-mode,
requirepass) and is not derivable from the repository. Not claimed either way —
this is filed on the configuration as written, which is enough to act on.

Acceptance criteria

  • A deployment cannot end up with protected-mode no and an empty
    requirepass at the same time: the play fails with a named reason, rather
    than rendering a config that is open by default.
  • vault_redis_password either resolves through the canonical secrets manager
    or its absence is a hard error on this path — never a silent ''.
    Per the standing rule, credentials come from the secrets manager, never a
    bare value or a parallel path.
  • bind is not a wildcard unless authentication is in effect.
  • A test asserts the combination, not the directives separately. Each of the
    three is defensible alone; only together are they an open instance, so a
    guard that checks them one at a time cannot see this.

Note on the last criterion

This is the shape that made it survive review: bind 0.0.0.0 is ordinary for a
containerised service, protected-mode no is the documented way to allow that,
and an empty password is invisible because the Jinja guard omits the directive
rather than emitting an empty one. Nothing is wrong on any single line. The guard
therefore has to assert the conjunction.

Activity

  1. mrveiss commented on Sep 26, 2026

    @mrveiss
    OwnerAuthor

    Gate landed in #17444 (1db87b0c922f), and host evidence raises the severity

    Verified the finding independently before acting, then checked it against a
    provisioned node rather than only the templates. The host evidence is worse
    than the template reading suggested.

    What the daemon reads today

    The packaged fallback the unit actually starts with contains one directive:

    port 6379
    

    No bind, no protected-mode, no requirepass. So on a provisioned node,
    right now:

    Bind no directive → all interfaces (confirmed listening on 0.0.0.0:6379)
    Authentication none
    protected-mode defaults to yes

    The store is already wildcard-bound and passwordless. protected-mode is the
    only control holding it shut.
    That is not a latent risk in a template — it is
    the current state of a running node.

    The rendered-but-unread config carries bind 0.0.0.0, protected-mode no and
    no requirepass. #17434 is what makes the daemon read it. So the ungated change
    would have switched off the single control currently protecting the store —
    turning "refuses every non-loopback connection" into "accepts anyone".

    Why the gate is not a behaviour change

    Because remote access does not work today (protected-mode is on by default),
    keeping protected-mode yes when no password resolves reproduces current
    behaviour exactly: loopback serves, everything else is refused. There is no
    regression to weigh against the safety — the alternative was not "keep working",
    it was "start answering strangers".

    With a password the intended protected-mode no is restored unchanged.

    What shipped

    One trimmed expression, _redis_auth, answers "is this authenticated?" for both
    protected-mode and requirepass, so the two cannot disagree.
    default('', true) substitutes on any falsy value — without the boolean
    argument | string turns 0 and False into the truthy strings "0" and
    "False", which would reopen the store on a config that set the key to a falsy
    scalar. Trimming matches the role's own convention at tasks/main.yml:249.

    repo_tests/redis_unauthenticated_conjunction_17551_test.py asserts the
    conjunction, as this issue's last criterion asks — not the three directives
    separately. Five falsy password spellings, a negative control proving the
    detector can see an open config, and a contrast pair proving the guard does not
    simply pin protected-mode on. Three mutations bite: restoring the
    unconditional directive (5 failures), dropping the trim (1), dropping the
    boolean default (3).

    Two things deliberately NOT done here

    The real credential. redis_password resolves through vault_redis_password,
    which nothing assigns. The SLM-generated secret is not a counterexample:
    tasks/main.yml:246 assigns it to redis_client_password — the client
    credential — and reads it after this template renders. The server stays nopass.
    That split is #16627 and this gate does not close it; it makes the absence
    of a password safe instead of silent.

    A tree sweep asserting the vault key is never assigned. That needed a *.yml
    glob, and repo_tests/_glob_declared_uncovered.py says not to add an entry
    there to make a new uncovered dependency pass. The evidence is recorded here
    instead; the test checks the part the gate depends on — that redis_password is
    still an indirection with an empty fallback. Losing the sweep costs nothing: an
    assigned vault key makes the store safer and the conjunction guard holds either
    way.

    Remaining on this issue

    The conjunction is now guarded at render time. What is still open is giving the
    store an actual credential so protected-mode no becomes legitimate rather than
    merely unreachable — which is #16627's subject, and the reason this issue should
    stay open after #17444 merges.

  2. mrveiss commented on Sep 26, 2026

    @mrveiss
    OwnerAuthor

    Correcting my own comment above, and the gate is reverted

    My earlier comment on this issue said, of a provisioned node:

    protected-mode defaults to yes … The store is already wildcard-bound
    and passwordless. protected-mode is the only control holding it shut.

    That is wrong. Measured on the node rather than reasoned from the packaged
    config: protected-mode is no. There is no such control.

    I had inferred it from the packaged configuration file containing only port 6379, and concluded the default applied. It does not, because the setting
    never comes from a configuration file at all — the packaged launcher passes
    --protected-mode no on the command line, unconditionally, after the config it
    is handed. Right instrument, wrong layer: I read the file the daemon loads and
    never read the command line that starts it.

    The consequence for this issue's fix

    The gate merged into #17444 — rendering protected-mode yes when no password
    resolves — could never have worked. Redis documents command-line arguments
    as producing "an in-memory temporary config file (possibly concatenating the
    config file passed by the user, if any)", so the launcher's flag overrides
    anything the template writes. The comment I shipped with it, claiming the store
    would "serve loopback and refuse every other interface", was false.

    A gate that reports protection it does not provide is worse than no gate. It is
    the same false-assurance shape this PR spent the day clearing, committed inside
    the fix for it.

    Reverted at the owner's direction. #17444 is now the config-path fix and
    nothing else: the _redis_auth normalisation and the protected-mode branch are
    gone, test_redis_password_provisioned_16627.py is restored to main
    unchanged, and redis_unauthenticated_conjunction_17551_test.py is removed —
    its conjunction assertion was measured against one of three renderers while
    reading as a statement about the store, which CodeRabbit's finding about the
    inline config in deploy-native-services.yml exposed.

    What replaces it

    #17572 records the measured state of a provisioned node — unauthenticated,
    +@all on the default user, network segmentation as the only control — filed
    as a sub-issue of #16627, which owns the missing credential. The launcher
    override is documented there so the next person does not try to fix this in the
    config layer either.

    This issue's own remainder is unchanged: the rendered config should not be able
    to produce an open store, and that has to be asserted at every renderer,
    not one.

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