Skip to content

security(redis): the compose file publishes an unauthenticated Redis on all interfaces #17500

Open

Description

@mrveiss

Raised by the CodeRabbit review on #17438 (docs/api/redis-documentation.md:26), verified against the compose file. Filed separately because #17438 is documentation-only and this is a docker-compose.yml defect — the doc merely mirrors it.

Defect

docker-compose.yml publishes Redis on every host interface while the image runs with protected mode off and no password:

    ports:
      - "${AUTOBOT_REDIS_PORT:-6379}:6379"
      - "127.0.0.1:8001:8001"  # RedisInsight UI (localhost only)

The two lines are adjacent and only the second is bound to loopback. A Docker port mapping without an address binds 0.0.0.0, so the Redis line reaches anything that can route to the host.

Three facts have to hold together for this to be exploitable, and all three do:

  1. No loopback binding — the line above, versus the RedisInsight line that has one.
  2. Protected mode is off, and not by our choice. The redis/redis-stack:7.4.0-v1 entrypoint invokes redis-server with --protected-mode no hardcoded. Protected mode is the fallback that would otherwise refuse external commands from an unauthenticated client, so the usual safety net for an unconfigured Redis is already removed by the image.
  3. No password. REDIS_ARGS=--appendonly yes --save 60 1 --maxmemory 512mb --maxmemory-policy allkeys-lru sets no --requirepass, and no mounted config supplies one.

Impact: unauthenticated read/write to the whole keyspace for anyone who can reach the host on that port, including FLUSHALL and CONFIG SET. Redis holds session and cache state here, so this is not only data exposure.

AUTOBOT_REDIS_PORT changes the port but not the binding, so an operator who sets it gets the same exposure on a different number.

Suggested fix

  • Bind to loopback like the neighbouring line: "127.0.0.1:${AUTOBOT_REDIS_PORT:-6379}:6379", and where a non-local client genuinely needs Redis, reach it over the compose network by service name rather than the published port.
  • Set --requirepass from the canonical secrets manager, never a literal in the compose file or the environment block. Defence in depth: the binding is the control, the password is the backstop.

Acceptance criteria

  • The Redis port mapping carries an explicit loopback address, or a documented justification exists for why it must not.
  • A test or guard asserts no published port in docker-compose.yml binds all interfaces without an explicit allowance — the class matters more than this one line, since the same mistake is one copied line away.
  • --requirepass is supplied from the secrets manager, and a startup check fails closed when Redis is reachable without auth.
  • docs/api/redis-documentation.md shows the corrected mapping, so the documentation stops teaching the insecure form.

Activity

  1. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    Confirmed live on the deployment — and the firewall does not mitigate it

    This issue was derived from the compose file. Measured read-only against the running deployment
    just now, all three conditions hold in practice, and a fourth makes it reachable rather than
    theoretical. Nothing was modified, no credentials were used, and no key contents were read.

    Condition from the issue Observed on the running system
    Published on every interface bind is all-interfaces, confirmed
    No password the password parameter is empty
    Protected mode off server info and keyspace size answer with no credentials
    (new) Firewall does not compensate the port is allowed from a routable /24, not loopback

    The keyspace is non-empty, so this is live state rather than an idle instance.

    The fourth row is the point of this comment. The issue says the exposure reaches "anything that
    can route to the host", which reads as a conditional. On this deployment the host firewall has an
    explicit allow for that port scoped to a routable /24, so the exposure is not hypothetical and is
    not blocked by default-deny — every host on that subnet has unauthenticated read/write to the whole
    keyspace, including destructive commands.

    This is the one home for the defect

    A sibling finding (#17635) covers the metrics service being firewall-open and unauthenticated. That
    is the same class — reachable implies authenticated — but a different instance with a different
    repair, so it stays there and this stays here. Both should be weighed together when the class is
    scheduled, because the store holds data where the metrics service holds labels.

    Note for whoever fixes this

    Any password must come from the canonical secrets manager, never a literal in compose or an env file.
    A fix that hardcodes a value trades one finding for another.

    The repair also has an ordering property worth stating: the store is currently usable because it is
    unauthenticated, so enabling the password and narrowing the bind will break every client that does not
    yet supply credentials. Those land together or the deployment loses its cache.

    What was not established

    Whether anything on that subnet currently connects to the store, and whether the exposure predates the
    current deployment. Both need evidence I did not gather; neither changes the finding.

  2. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    Scoping: clients exist, so the cheap repair is not available

    Established from the tracked tree, not live connection state.

    1. Client inventory — remote clients exist by design

    The ansible inventory places these in distinct host groups, one host each:

    group reaches Redis how
    backend group_vars/backend.yml:108 — AUTOBOT_REDIS_HOST: "{{ redis_host }}"
    aiml group_vars/aiml.yml:91 and :132 — REDIS_HOST: "{{ redis_host }}"
    database hosts the store itself

    group_vars/infrastructure.yml:71 resolves the endpoint to
    redis://{{ infrastructure.hosts.database }}:{{ infrastructure.ports.redis }} —
    so redis_host is the database host, and backend and aiml are configured
    to reach it across the network.

    So the answer to the deciding question is yes. Not from live state — from
    configuration that is committed. Consequences:

    • Narrowing the bind to loopback would break the deployment. The strictest-is-
      cheapest option is off the table.
    • The credential must reach backend and aiml in the same change, or those
      services lose the cache the moment requirepass lands.

    One negative result worth recording: the only literal remote address in the tree
    is in a frontend test fixture
    (autobot-frontend/src/config/__tests__/ssot-config.spec.ts), not a client
    config. And the SSOT defaults are local — ssot_config.py:187 defaults the host
    to loopback, :2037 to localhost. So there is no additional undeclared client
    beyond the two groups above.

    2. Which firewall description is authoritative — and the dead one is worse

    Authoritative: roles/redis/tasks/main.yml:175-184

    - name: "Redis | Configure firewall - Allow Redis port"
      ufw:
        rule: allow
        port: "{{ redis_port }}"
        from_ip: "{{ network_subnet }}"

    That is what produces the live subnet-scoped rule, and it is the file a repair
    belongs in.

    Unapplied: autobot-infrastructure/shared/config/ufw-defaults.sh:108

    ufw allow 6379/tcp comment 'Redis Stack'

    Referenced by exactly two things: docs/developer/INFRASTRUCTURE_DEPLOYMENT.md
    and itself. And it is more permissive than reality — no from restriction at
    all, so it allows from anywhere, where the applied rule is subnet-scoped. So it is
    not merely dead:

    • a repair written there changes nothing and will read as done;
    • if that script is ever wired up, it widens the exposure rather than
      narrowing it.

    That second point is worth fixing regardless of this issue's outcome.

    3. A latent hazard in the authoritative rule itself

    roles/redis/defaults/main.yml:7:

    network_subnet: "{{ lookup('env', 'NETWORK_SUBNET') or lookup('pipe', '... slm-secrets.env ...') or '' }}"
    

    The final fallback is the empty string, and from_ip is used unguarded at
    main.yml:180 and :191. What ufw does with an empty from_ip needs checking
    before the repair is written — if it degrades to "from anywhere", then a host
    where neither the environment variable nor the secrets file resolves is silently
    wide open, and nothing reports it. I have not established that behaviour and am
    not assuming it.

    4. Recommended repair, and where it lives

    All three parts in one change, since any one alone leaves a broken or exposed
    state:

    1. Credential from the canonical secrets manager, rendered into the server
      config by roles/redis — never a literal in compose, never an env file.
      Note the existing obstacle: redis_password resolves through
      vault_redis_password, which nothing in the tree assigns, and the
      SLM-generated secret is adopted into redis_client_password — the client
      credential — after the server config renders. That split is security(redis): P1b — SLM-generated Redis password, every consumer authenticates as default while the server stays nopass (#13568) #16627 and it is
      the precondition, not a detail.
    2. Client config in group_vars/backend.yml and group_vars/aiml.yml,
      from the same source, in the same change.
    3. Firewall narrowed from a subnet to the specific client hosts, in
      roles/redis/tasks/main.yml. There is already a precedent for exactly this in
      the same role — roles/redis/tasks/redis_exporter.yml:89-96 scopes a rule with
      from_ip: to one host, for the Prometheus scrape.

    Evidence must be a refusal: an unauthenticated client rejected on a
    provisioned node, read back from the host. Not the absence of a report.

    5. Relationship to #17572

    This issue is the compose path; #17572 is the provisioned path, and both
    are open. They share the mechanism worth stating once: the vendor start path
    disables protected mode unconditionally — this issue's point 2 records the image
    entrypoint hardcoding --protected-mode no, and #17572 records the packaged
    redis-stack-server wrapper passing --protected-mode no after the config
    file it is handed, so anything the config sets is overridden.

    That means protected mode cannot be part of either fix. A gate attempted in
    the config layer was reverted from #17444 for exactly that reason. The only
    controls available are the credential, the bind, and the firewall.

    Not implementing — scoping only.

  3. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    The open question from my scoping is settled: an empty from_ip does degrade to "from anywhere"

    Answered from the installed module source — community.general 9.5.2 — not by
    applying a rule. No firewall was touched.

    plugins/modules/ufw.py:405:

    cmd = ' '.join(map(itemgetter(-1), filter(itemgetter(0), cmd)))

    Each fragment is a [value, rendered] pair, and from_ip is built at :568-571
    as [value, "from %s" % value]. filter(itemgetter(0), …) keeps a fragment only
    when the value is truthy, so an empty from_ip drops the from clause
    entirely — not "from empty", but no source clause at all:

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

    Valid syntax, rc 0, no warning, normal changed/ok, and ufw applies no source
    restriction when none is given. So yes — and it fails in the direction that
    does not announce itself.

    (The module declares from_ip with default='any' at :354, which is a red
    herring: defaults apply only to an omitted parameter, and this task passes one
    explicitly. The widening is the drop, not the default. Same destination either
    way.)

    So this issue is worse than my scoping measured, and in two respects:

    1. The subnet scoping I called the authoritative control is only a control while
      the variable resolves. roles/redis/defaults/main.yml:7 ends in or '', and
      from_ip is unguarded at main.yml:180 and :191.
    2. It is not confined to the cache — the same variable feeds three more rules in
      roles/browser/tasks/main.yml (:347, :361, :374), and eight definitions
      of it across the inventory fall back to either '' or 0.0.0.0/0, both of
      which mean anywhere
      .

    That spans two roles and is a different fix site from this issue, so I filed it as
    its own defect rather than folding it in here: #17642. It also has a lineage
    worth knowing — #3301 and #7162 both fixed this variable for the opposite symptom
    (an empty value breaking the play) by making it resolve, which replaced a loud
    failure with this silent one.

    One precision, so this is not read as a live claim: I am not saying the
    deployment is open right now. #17635's read-only measurement records the cache as
    allowed from a routable /24, so the variable did resolve on that node. This is a
    reachable latent path, not an active exposure — and the reason it deserves a fix is
    that the two states are indistinguishable in the playbook output.

    What it changes for this issue's repair: part 3 (narrow the firewall to the
    client hosts) cannot be expressed as a variable with a fallback.
    If the host list
    does not resolve, the play has to stop. Otherwise the narrowing is only as strong
    as a lookup succeeding, and its failure mode is the thing it was meant to prevent.

  4. added this to the v0.9.0 milestone on Sep 27, 2026
  5. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    #16627 is not a hard blocker — the server side is assignable today, through a different key

    Read-only, from origin/main. This corrects my own earlier note on this issue,
    which called the vault_redis_password orphan "the precondition for the credential
    half". It is a precondition for one of three renderers, not for the change.

    Q1 — is the server side genuinely unassignable?

    No. There are three renderers that can emit requirepass, and they do not
    share a variable:

    Renderer requirepass keyed on Emits today?
    roles/redis/templates/redis-stack.conf.j2:79-80 redis_password ← vars/main.yml:22 ← vault_redis_password No — the {% if %} is false
    playbooks/deploy-native-services.yml:98 autobot_redis_password, else env AUTOBOT_REDIS_PASSWORD Yes — conditionally, see below
    playbooks/deploy-hybrid-docker.yml:144 same Yes — same condition

    vault_redis_password has 5 occurrences in the whole tree and 0 producers —
    every one is a consumer or a comment, and there is no ansible-vault file anywhere.
    So the role path's server credential is wired to a variable nothing assigns.

    But AUTOBOT_REDIS_PASSWORD — the same key the client side already reads —
    does set requirepass on the two paths that ship. So the credential already exists
    and already flows; the role path simply does not consume it on the server side.

    #17500 is self-contained if it uses AUTOBOT_REDIS_PASSWORD. It needs #16627
    only if it insists on the vault_ route. #16627 is a wiring blocker for one
    renderer, not a blocker for this issue.

    One precedence trap, because it makes the obvious fix wrong:
    roles/redis/defaults/main.yml:10 sets redis_password: "", and
    roles/redis/vars/main.yml:22 sets it to {{ vault_redis_password | default('') }}.
    Role vars/ outranks role defaults/, so the orphan is what takes effect. A
    fix applied to defaults/main.yml would change nothing and would look correct.

    Q2 — what the client already does: it sends a password the server ignores

    Yes, and this is the good news.

    _shared/tasks/read_redis_password.yml:36-46 greps AUTOBOT_REDIS_PASSWORD out of
    the install-time secrets file on the control node. roles/redis/tasks/main.yml:246
    then adopts it into redis_client_password — but only when the probe is non-empty
    and the client credential is still empty (:249-250).
    defaults/main.yml:16 is redis_client_password: "{{ redis_password }}", so absent
    adoption the client follows the server.

    The split is deliberate and guarded, not an oversight. tasks/main.yml:236-238:

    … password while the server stays nopass. redis_password still owns the
    server's requirepass and is NOT touched here (P2, #16628, enforces).

    and tests/test_redis_password_provisioned_16627.py:166 asserts
    "redis_client_password" not in server.

    So on any node where the secret exists: clients authenticate, the server accepts
    anything.
    The change is the smaller one — the server starts requiring what clients
    already send.

    That existing test does not block the fix, which is worth stating so nobody
    reads it as one: the correct fix sets redis_password (the server variable) from
    the shared source. It does not put redis_client_password into the server template,
    which is the only thing :166 forbids.

    Q3 — ordering: two parts, not three

    1. Server requirepass on the role path, keyed to AUTOBOT_REDIS_PASSWORD, plus
      the bind/firewall narrowing — one change.
      These cannot be separated: the
      narrowing without the credential leaves the exposure, and the credential without
      the narrowing is the only control on a wildcard bind.
    2. Clients: already done. Nothing to land — see Q2.

    #16627 is not in the sequence unless the vault_ route is chosen deliberately.

    But one thing does have to be closed in the same change, or the fix has a
    bypass.
    Both shipping renderers guard requirepass on
    (env AUTOBOT_REDIS_PASSWORD and not env AUTOBOT_REDIS_USERNAME). Set a username
    as well as a password and requirepass is skipped — and nothing in the tree
    creates an ACL user to take over:

    • group_vars/database.yml has security.redis_security.acl_enabled: false
    • the only aclfile directive is templates/redis/redis-stack.conf.j2:132
    • playbooks/deploy-database.yml:135 templates redis-acl.conf.j2, and that
      template does not exist in the tree
      — the task is only reachable when
      acl_enabled is true, so today it is latent rather than broken

    So password + username together produce a store with no authentication at
    all
    , by design of the conditional. A repair that only adds requirepass inherits
    that hole.

    Where the #17642 guard goes

    Not a defaulted variable — that is the whole finding. Proven at
    community.general 9.5.2 ufw.py:405: an empty from_ip drops the from clause
    entirely, rc 0, no warning, rule applies from any source.

    • Remove the or '' fallback from roles/redis/defaults/main.yml:7.
    • Add an assert in roles/redis/tasks/main.yml immediately before the two ufw
      tasks at :175 and :186, requiring the source list to be non-empty, so an
      unresolved value stops the play instead of widening the rule.
    • A genuinely-open rule, if ever wanted, is stated at the task — the way
      roles/common/tasks/main.yml:161 already does with default('any') — never
      inherited from a failed lookup.

    Two sightings that belong to existing issues, not here

    Both found while answering the above; recording where they go rather than
    re-diagnosing them:

    Refs #16627, #16628, #17434, #17441, #17551, #17572, #17642

  6. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Investigation and repair proposal — written only, nothing changed

    No host state, config or credential was modified. Every probe below is read-only.

    Correction: the compose file is not what is running

    The title attributes the exposure to docker-compose.yml. It is not that, on this deployment: the container runtime is not installed in the environment the service runs in, so no compose file is in effect. Redis runs as a native systemd unit (redis-stack-server.service, active), started from the package's own config file.

    The compose file does contain the same defect — its Redis port mapping has no bind address while the RedisInsight mapping directly beneath it is pinned to loopback — so it would reproduce this wherever compose is used. But repairing only the compose file would change nothing here, and that is worth knowing before anyone writes the fix.

    The exposure, as measured

    Three layers are open independently, and a firewall rule permits the traffic:

    layer state evidence
    bind address all interfaces bind 0.0.0.0 in the service's package config; ss shows LISTEN 0.0.0.0:6379
    protected mode explicitly disabled protected-mode no in the same file
    authentication none configured redis-cli ping returns PONG with no credentials
    firewall explicitly allowed rule [10] 6379/tcp ALLOW IN <a routable /24>, in a table of 48 rules, default policy deny (incoming)

    protected-mode no matters more than it looks. Protected mode is Redis's own fallback: with no password set it refuses non-loopback connections. It has been turned off, which removes the guard that would otherwise have made the open bind harmless.

    The full table was read, not a head of it. The earlier 4-rule reading that shaped this issue came from a truncated view; the table is 48 rules and rule 10 is the relevant one.

    Auth is available and unconfigured, not absent

    This determines the repair. The client side already supports credentials: AUTOBOT_REDIS_PASSWORD and AUTOBOT_REDIS_USERNAME are both registered env vars with client support (the username path landed in #16626). Nothing needs building — the server simply has no requirepass and the services pass no credential.

    Who actually depends on the reachability

    This is the finding that changes the risk of the repair. Live client list: 26 connections across 2 distinct addresses — loopback, and one of this host's own interfaces. No remote machine is connected.

    So the /24 allowance currently serves nothing. It can be removed without breaking any peer that exists today. What it does mean is that ~22 connections reach Redis via the host's own routable interface rather than loopback, so a bind narrowed to loopback alone would break them.

    The repo is the source, and it is currently broader than the running state

    repo location content
    autobot-infrastructure/shared/config/ufw-defaults.sh:108 ufw allow 6379/tcp — no source restriction at all
    autobot-infrastructure/shared/config/redis-optimized.conf:29-30 bind 0.0.0.0, protected-mode no
    autobot-infrastructure/autobot-database/templates/redis.conf:5,7 bind 0.0.0.0, protected-mode no

    Neither template sets requirepass.

    The live rule is narrower than the repo's. Running state restricts 6379 to one /24; the repo would allow it from anywhere. So re-running the provisioning script widens the exposure. That inverts the usual assumption that the repo is the safe state to converge on, and it means the repo change is required before any re-provision, not merely desirable.

    Adjacent, same shape, not fixed here: the same file allows 5432/tcp and 8001/tcp with no source restriction. This is a class in that script, not one rule.

    Proposed repair — two phases, in this order

    Phase 1 — close the reach. No credentials involved, no outage risk.

    1. Repo: remove the unrestricted 6379/tcp allow from ufw-defaults.sh.
    2. Repo: in both Redis config templates, set protected-mode yes and narrow bind to loopback plus the internal interface the local services actually use.
    3. Apply through the builtin code-sync / self-update path.
    4. Verify: ss no longer shows a 0.0.0.0 bind for 6379; the existing connections survive; an off-host connection attempt is refused.

    Phase 1 is safe because no remote peer exists. It is also reversible by the same path.

    Phase 2 — authentication. Scheduled window, human-approved.

    1. A password is provisioned through the canonical secrets manager and approved through the approval gates (Umbrella: SLM Data hygiene page — detect and safely clean up orphaned data across services #17038). No agent generates, rotates or writes it.
    2. Repo: templates gain requirepass sourced from that secret.
    3. Restart Redis, then restart dependents.

    What breaks if the order is wrong, which is the part that needs stating: Redis rejects AUTH when no password is set, so clients cannot be pre-configured against the current server — the credential cannot be rolled out ahead of the server change. Server and clients therefore have to change inside one window. Server-first with clients unaware is a total outage of backend, worker and SLM paths, because Redis is load-bearing for all of them. Clients-first is an immediate error on every connection. If zero-downtime is required, the ACL route — add a credentialed user alongside the existing nopass default, migrate clients, then remove nopass — achieves it at the cost of a three-step migration.

    What I could not establish

    • Why ~22 connections use the routable interface rather than loopback. AUTOBOT_REDIS_HOST defaults to loopback, so something overrides it. Until that is found, step 2 has to keep the internal interface in the bind list; narrowing to loopback alone would break those clients. This is the one item that could turn Phase 1 from zero-risk into an outage, and it should be resolved before the change is written.
    • Whether the /24 is reachable beyond this host in practice. I observed the firewall rule and the absence of remote clients; I did not test reachability from another machine, which is the only way to confirm the exposure is live rather than latent.
    • How the live rule came to be narrower than the repo's. Either it was narrowed by hand or applied by a path other than ufw-defaults.sh. This matters because it determines whether the repo fix is sufficient or whether a second mechanism also writes this rule.
    • Whether any non-connected consumer is expected to reach Redis remotely — a scheduled job or a node currently offline would not appear in a live client list. The client list proves nothing about intent, only about the present moment.
  7. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Addendum: the blocking gap is resolved, and Redis is the only one of the three ports with every layer open

    Still read-only. No host state, config or credential changed.

    1. Why ~22 connections arrive over the routable interface — resolved

    The deployed backend's environment file sets the Redis host to the host's own routable interface address, not loopback:

    AUTOBOT_REDIS_HOST=<this host's routable address>
    AUTOBOT_REDIS_PORT=6379
    AUTOBOT_REDIS_URL=redis://<this host's routable address>:6379
    

    Traced from the connections rather than assumed: the established sockets belong to uvicorn (the backend unit) and a python3.14 worker, and their source address is routable — which only happens when the destination is too. A connection to loopback has a loopback source.

    So the backend talks to a local Redis by going out to the host's own routable address and back in. That is the whole reason the bind cannot be narrowed to loopback alone.

    Two corrections this forces:

    • autobot-slm-backend.service sets the host to loopback explicitly, in its unit Environment=. So the SLM path is already correct and only the backend and worker take the routable route. My earlier framing implied one uniform misconfiguration; it is one service's env file.
    • The repo does not choose this. infrastructure.yaml and distributed.yaml both carry host: "" with the comment "Set via AUTOBOT_REDIS_HOST env var at deploy time". The routable value is injected by whatever generates the deployed .env, so the repo is neutral here and the repo-side fix for Phase 1 is not in those files.

    What this does to Phase 1. There are now two orderings, and the cheaper one is available because no remote peer exists:

    • Option A (preferred): repoint the backend/worker env to loopback first, restart them, confirm the routable-source connections are gone, and only then narrow the bind. This ends with a loopback-only Redis and needs no interface in the bind list. It is not a credential change.
    • Option B: leave the env alone and keep the internal interface in the bind while dropping the firewall allow. Closes external reach with no service restart, but leaves Redis listening on a routable interface, so it is a partial repair.

    A is the one I would put to the user, because B's end state still depends on the firewall being the only thing in the way.

    2. Whether remote reachability is intended, not just unused

    No repo config points a remote host at this Redis — there are no non-loopback redis:// URLs in the infrastructure config outside templated placeholders.

    One piece of contrary evidence worth stating rather than burying: an alerting rule refers to "Redis service on VM3", which implies a multi-VM topology where Redis sits on its own node and other nodes reach it. That is a design intent, not a live dependency — nothing remote is connected, and nothing in config points anywhere remote. But it means Phase 1's safety rests on the current single-node reality, and if the multi-VM layout is ever realised the loopback-only end state would have to be revisited deliberately rather than discovered.

    This is also the honest answer to my earlier fourth gap: a live client list cannot see a scheduled job or an offline node. What I can now say is that no configuration expects a remote consumer, which is stronger than "none is connected" and weaker than "none is intended".

    3. The adjacent ports — Redis is the only one with all layers open

    port bind auth verdict
    Redis all interfaces, protected-mode no none — PING answers unauthenticated all three layers open
    PostgreSQL all interfaces enforced — pg_hba refuses an unconfigured user even on loopback unrestricted port, authenticated service
    Backend API all interfaces health endpoint answers 200 unauthenticated, by design for a probe not established

    So the answer to whether these are one problem: they are not. PostgreSQL has the same firewall exposure and a working authentication layer behind it, which makes it a hardening item rather than an open door. Redis is the only one where the port, the fallback and the authentication are all open at once.

    Gap, and I am not closing it: I could not characterise the backend API's authenticated surface. Three probes (/openapi.json, /docs, /api/auth/me) returned no response within 6 seconds rather than a status code, so I have one data point — a health endpoint that is public by design — and no basis to say whether authenticated routes are properly gated. That needs its own look and should not be folded into this issue's conclusion either way.

  8. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Provenance of the readings above — and the update is mid-flight, not finished

    A deployment update is in progress as I write this. Flagging it before anything in this issue gets read as current, because one of my numbers is load-bearing and it is the one a restart invalidates.

    Every live reading above was taken before 07:08 EEST on 2026-09-28

    reading stable across a restart?
    bind 0.0.0.0 and protected-mode no in the service config yes — the updater does not write this file
    48-rule firewall table, rule 10 allowing the port from a routable /24 yes — not written by the updater
    redis-cli ping answering unauthenticated yes, unless the config changes
    the deployed backend env pointing at the host's routable address no — an update may rewrite the env file
    26 connections across 2 distinct addresses, no remote peer no — rebuilt on any restart

    The last row is the entire basis for "Phase 1 is safe because no remote peer exists." It deserves the caveat more than the rest.

    The update is partially applied, which is worth more than either endpoint

    Unit start times, read at 07:09:06 EEST:

    autobot-slm-backend     Mon 2026-09-28 07:08:17 EEST   <- restarted, in this window
    autobot-backend         Sun 2026-09-27 23:10:54 EEST   <- NOT restarted
    redis-stack-server      Sun 2026-09-27 01:04:36 EEST   <- NOT restarted
    

    So the synced tree matches origin/main while two of three services are still running pre-update code. That is exactly the shape #16310 describes, observed rather than inferred: a current SHA is not a completed update. Any conclusion drawn from this host in the next few minutes has to say which half it is about.

    What the partial state already tells us, for free

    One service restarted and reconnected while Redis stayed up, so this is a natural experiment on the load-bearing number. Client list at 07:09, mid-update:

    loopback : 4
    routable : 23
    total    : 27
    

    Still two address classes, no remote peer — and the loopback group is intact at 4 after the SLM service restarted into it. That is consistent with autobot-slm-backend.service setting the Redis host to loopback in its unit environment, which I reported earlier from config: the restart confirms it behaviourally rather than by reading a setting.

    So the safety argument for Phase 1 has now survived one service restart. It has not survived a Redis or backend restart, which is where the remaining risk to that number sits.

    Correction to a question I had left open

    I had listed "why do ~22 connections arrive over the routable interface" as unresolved, and suggested watching the reconnection to find the override. That is already answered in the addendum above: the deployed backend environment file sets the Redis host and URL to the host's own routable address. The restart-watching instrument would confirm it but is not needed to find it.

    What the restart is needed for is a different question: whether this update rewrites that env file. If the new deployment regenerates it with the same routable value, the Phase 1 repoint has to be made where the file is generated, not in the file. If it regenerates it with loopback, part of Phase 1 has already happened by accident and should be verified rather than repeated.

    What I will re-measure once services have settled, and what I will not

    Re-measure: the client list and its address classes, and the backend env's Redis keys.

    Not re-measure: the firewall table and the config-file contents — but I will re-check them rather than assume, precisely because this issue started from a truncated firewall read and the cost of assuming was the shape of the original report.

    No host state, config or credential has been changed at any point in this investigation, and nothing about the update is mine to drive.

  9. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Re-measured after the update completed — the load-bearing number held, and the update introduced a contradiction

    Read at 2026-09-28 07:34 EEST, deployed tree at 7adaca8c296d. Read-only throughout.

    What restarted

    autobot-backend       active     Mon 2026-09-28 07:17:23 EEST   <- restarted
    autobot-slm-backend   active     Mon 2026-09-28 07:09:14 EEST   <- restarted
    redis-stack-server    active     Sun 2026-09-27 01:04:36 EEST   <- NOT restarted
    autobot-worker        inactive   —
    

    Redis itself never restarted, so the entire connection set was rebuilt by clients reconnecting. That is the cleanest possible re-measurement of the number this issue's safety argument rests on.

    The load-bearing finding survived a full backend restart

    loopback : 4
    routable : 22   (one distinct address)
    total    : 26
    distinct addresses: 2
    

    Identical to the pre-update reading: two address classes, no remote peer. The backend went down, came back, and re-established 22 connections from the same single routable address. So "Phase 1 is safe because no remote peer exists" is now a claim that has survived a restart of every client, rather than a snapshot of one moment.

    The firewall table and service config, re-checked rather than assumed

    Both unchanged: bind 0.0.0.0 and protected-mode no still in the service config, PING still answers unauthenticated, 48-rule table with rule 10 still allowing the port from a routable /24. I re-read them instead of reasoning that the updater does not touch them, because a truncated read of exactly this table is what gave the original report its shape.

    The update rewrote the env file and left it self-contradictory

    The backend env file was rewritten at 07:17:49, inside the update. Its Redis keys now disagree with each other:

    AUTOBOT_REDIS_HOST=<the host's routable address>     <- unchanged
    AUTOBOT_REDIS_PORT=6379
    AUTOBOT_REDIS_URL=redis://127.0.0.1:6379             <- now loopback
    

    One key says routable, the other says loopback, for the same fact.

    And the behaviour settles which one is in effect: 22 connections still arrive from the routable address after the restart, so the loopback URL changed nothing.

    The reason is in the config model, and it is the sharper half of this finding:

    • MiscConfig.redis_url is a field reading AUTOBOT_REDIS_URL.
    • config.redis_url is a property that ignores it and recomposes the URL: f"redis://{self.vm.redis}:{self.port.redis}" — that is AUTOBOT_REDIS_HOST and AUTOBOT_REDIS_PORT.

    Consumers use the property (service_config.py:270, and others). So AUTOBOT_REDIS_URL is read into a field that the effective URL does not come from. startup_validation.py:40 lists it as a validated variable with the comment "composed by Ansible from backend_redis_host/port" — so it is composed from the same inputs, stored, validated, and then not used for the connection.

    Why this matters beyond tidiness: the key that looks authoritative is the one spelled as a URL, validated at startup, and now pointing at loopback. Anyone reading this env file to answer "how does the backend reach Redis?" gets loopback, and is wrong. The update moved the file from agreeing and wrong to disagreeing, with the wrong half winning, which is harder to notice than either.

    That is the same shape as the rest of this issue: two expressions of one fact, and the one that reads as the answer is not the one in effect.

    What this does to the repair

    It resolves the open question from my addendum. The Phase 1 repoint must change AUTOBOT_REDIS_HOST, at the point the env file is generated — the update demonstrably regenerates this file, so an edit to the file is overwritten on the next update. Changing AUTOBOT_REDIS_URL alone does nothing, which the update has now proved experimentally.

    Two items to add to the repair, neither of them mine to apply:

    1. The generator that composes these keys should emit one source of truth, or the property and the field should not both exist under the same name. A disagreeing pair will drift again.
    2. AUTOBOT_REDIS_URL is validated at startup but not used for the connection. Either it should be consumed or it should not be validated as though it governs anything.

    Still open

    • autobot-worker is inactive. It was one of the processes holding Redis connections before the update. Whether that is expected post-update or a casualty of it is not something I can tell from here, and it is outside this issue — flagging it because it changes what the connection census represents.
    • The backend API's authenticated surface, unchanged from my previous note: one public health endpoint observed, the rest uncharacterised.
  10. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Correction: my "self-contradictory env file" finding was wrong, and the cause was my own redaction

    Retracting the central claim of my previous comment. It is wrong, and the mechanism is worth stating because it is the same class as the defects this issue is about.

    What I claimed, and why it was false

    I reported that the update left the backend env internally inconsistent — the Redis host routable, the Redis URL loopback. Compared programmatically rather than by eye:

    HOST loopback: YES    URL-host loopback: YES    consistent: YES
    

    The file agrees with itself. Both point at loopback.

    The error was in my own redaction. I was masking addresses before displaying them with a substitution that rewrote KEY=<address> but did not touch an address appearing inside a URL value. So HOST=127.0.0.1 was displayed as HOST=<routable addr> while the URL's 127.0.0.1 passed through untouched — and I then read the two lines as disagreeing.

    I built an instrument that transformed one of two values I was comparing, and compared the transformed one against the raw one. Every conclusion in that section followed correctly from a difference my own tooling introduced. That is precisely the family this issue keeps producing: the measurement was fine, the thing being measured was not what I thought.

    What actually happened, which is better news than what I reported

    The update fixed the host. Before it, the backend env pointed Redis at the host's routable address; after it, loopback. I reported that as a regression when it was the opposite.

    The real defect is one of ordering, and it is still real:

    backend service started : 07:17:23
    backend env rewritten   : 07:17:49    <- 26 seconds LATER
    

    The service restarted, then its configuration was regenerated. So the running process kept the pre-update routable host, and the corrected file sat on disk unread. My 07:34 census — 22 connections from the routable address — was measuring a process that predated its own config by 26 seconds.

    Confirmed by the manual restart at 07:42:22, after which the census flips:

    07:34  loopback 4    routable 22     <- process older than its config
    08:46  loopback 22   routable 4      <- process running the config on disk
    

    So the corrected configuration took effect only on a restart that happened to come later for other reasons. Without it, the fix would have sat inert until the next restart, with the file and the process disagreeing and the file looking correct.

    What this does and does not say about the update path

    Does not: the update-all-nodes.yml playbook orders env regeneration before the backend restart, correctly. That ordering is not the bug.

    Does: whichever path ran this update restarted the backend before regenerating its env. I have not identified which path that was — I was searching for it when this was interrupted, and I am not going to name a culprit I have not found. What is established is the observable: a service restarted 26 seconds before the config it reads was written.

    That is the "does it need more steps" answer, and it is two steps rather than a rewrite:

    1. Regenerate configuration before restarting the service that consumes it, on whatever path performed this update — not only in the playbook where it is already correct.
    2. Run validate-env-files.py between generation and restart. It already encodes the URL/host/port consistency invariant at :154, and a repo-wide grep finds nothing that invokes it. A written check that nothing runs. It would not have caught this defect, since the file was consistent — but it is the natural gate for the class, and it currently protects nothing.

    Still open, unchanged

    • 22 of the 26 connections now arrive over loopback and 4 remain routable. Those 4 belong to something not yet restarted into the new config; the earlier finding that no remote peer is connected still holds, and has now survived two full restart cycles.
    • The firewall rule, bind 0.0.0.0 and protected-mode no are all unchanged, re-read rather than assumed.
    • Phase 1 of the repair is unaffected by any of this. If anything it is closer than reported: the env half has effectively been applied, so what remains is the bind and the firewall rule.
  11. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Second correction: there is no ordering bug. The defect is a verb.

    My previous comment said the update restarted the backend before regenerating its env. That is wrong, and so was the reasoning behind it. Traced properly from the ansible log.

    What the log actually shows

    Three ansible runs were interleaved, which is what my "26 seconds later" comparison silently spanned:

    PID 3053235   07:09   update-all-nodes.yml     backend env + restart, completed 07:10:44
    PID 3077276   07:15   deploy_role.yml          "Deploy AutoBot Role"
    PID 3077959   07:17   deploy_role.yml (backend role, in full)
    

    The 07:17 events came from the second playbook, not the first. I compared a restart from one run against an env write from another and read a causal ordering into it. Both runs order their own steps correctly.

    The real cause, and it is one word

    Within the backend role the sequence is right:

    07:17:49  Backend | Deploy .env to code dir
    07:17:50  Backend | Verify .env file readable
    07:17:51  Backend | Start service after env update (MVA-1633)
    07:17:53  Backend | Always-restart guard so backend can't be left dead (#12139)
    

    roles/backend/tasks/main.yml:1501:

    - name: "Backend | Start service after env update (MVA-1633)"
      ansible.builtin.systemd:
        name: autobot-backend
        state: started

    state: started is a no-op on a service that is already running. The env is written, the task succeeds, and the process keeps the configuration it started with. Confirmed on the host: the backend's start time did not move across the entire post-env sequence, and the corrected Redis host only took effect on a later manual restart.

    The Always-restart guard so backend can't be left dead (#12139) at :1521 is also state: started, so despite its name it cannot restart anything either.

    So two tasks assert in their names that they apply an env change or restart a service, and both use a verb that cannot do it. They pass because started succeeds — which is why this has been invisible.

    This is the same class as the rest of this issue: the name states the intent, the mechanism cannot deliver it, and success is reported either way. It is the third instance I have produced or found on this issue alone.

    Consequence

    Every env change applied by this role requires a separate manual restart to take effect, silently. Anyone verifying a config change by reading the deployed file sees the new value and concludes it is live. It is not, until something else restarts the service.

    The repair

    state: restarted on the post-env task, or condition a restart on the env template reporting changed. Plus a test asserting that the task following the env deployment uses a config-reloading verb — nothing asserts that today, which is why a started could sit in a task named "Start service after env update" indefinitely.

    Also still standing, independent of this: validate-env-files.py encodes the URL/host/port invariant at :154 and a repo-wide grep finds nothing that invokes it. It would not have caught this defect, but it is the natural gate for the class and currently protects nothing.

    Not yet fixed — the diagnosis is complete, the change is not written. It needs its own branch, and I am flagging it rather than claiming it done.

  12. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Third correction, and this one would have caused an outage: retracting the state: started finding entirely

    Do not apply the one-word fix I proposed. state: started at roles/backend/tasks/main.yml:1501 is correct and deliberate. Changing it to restarted would undo a documented decision and re-open a known outage mode.

    What I failed to read

    :477 — 1,024 lines above the task I quoted, inside the same block, unconditional, identically tagged:

    - name: "Backend | Stop service before env update (MVA-1633)"
        state: stopped
        changed_when: false
        failed_when: false
        tags: ['backend', 'config']

    And the rationale, at :474:

    Prevent systemd Restart=always race during env updates. The backend service may restart BEFORE the new .env is written if we use async notify. Stop explicitly, write env files, verify they exist, then start.

    And at :345, why the always: guard is also started:

    The MVA-1633 stop below explicitly stops autobot-backend, then ~670 lines of risky work run BEFORE the service is started again. If ANY task in that window FAILS, the block aborts straight to always:, which unconditionally starts the service so the backend is never left DEAD on a mid-deploy error (the 2026-07-23 login outage: backend stopped 12:01, not recovered until a manual start at 12:48).

    So the stop and the start are one restart, split across the role on purpose. started is not a no-op there because the service is stopped. The always: guard is started because it is a floor, not a reload. My proposal would have reintroduced precisely the race the split exists to prevent, and it was published as the answer to a direct question about the update path.

    The pattern, stated plainly

    This is the third instance of the same error in this one investigation:

    1. I compared a redacted value against an unredacted one and reported a config contradiction my own tool created.
    2. I compared a restart from one ansible run against an env write from another and reported an ordering defect.
    3. I read a task's verb without reading the 1,024 lines of context that give it meaning, and reported a one-word fix that would have caused harm.

    All three are the same shape, and it is the shape this issue is nominally about: a mechanism read without the context that determines what it does. The first two produced wrong findings. The third produced a wrong change, which is categorically worse — a wrong finding wastes a reader's time, a wrong change to a deploy path costs an outage.

    I had the general rule available and applied it elsewhere in this same investigation — I checked whether _scan_file blanked comments before trusting a test, I checked the full firewall table rather than a head of it. I did not apply it to a line I had already decided the meaning of.

    What remains true, and it is smaller

    My observation stands and my explanation of it does not: the backend's start time read 07:17:23 and did not move across the post-env sequence that ran at 07:17:49-53. Under a stop-then-start pair it should have moved to ~07:17:51. I do not know why it did not, and I am not going to offer a third theory. Candidates I have not checked: a tag-filtered run skipping both halves, the failed_when: false stop silently not stopping, or the interleaved-runs confusion for a third time.

    The one gap worth filing, which is not what I claimed

    Nothing asserts that the stop at :477 and the start at :1501 carry matching tags. They do today — both ['backend', 'config']. If they ever diverge, a tag-filtered run stops the backend and never starts it, which the MVA-1633 comment says the start task exists to prevent. The always: guard covers a task failing inside the block; it does not cover the block's tasks being filtered out.

    That is a real, small, checkable invariant: a test that the stop task's tag set equals the start task's. It is worth filing. It is not the defect I reported.

    validate-env-files.py being uninvoked also still stands, unrelated to any of this.

  13. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Chunking triage — a proposal, not an assignment

    Nothing was relabelled, moved or closed by this pass.

  14. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    B5 spec — first in the batch, and the fix is already written one line below the defect

    Batch B5 = #17500 → #17635 → #17642, one risk class (a service reachable from more of the network than intended), one review pass. Full spec at ~/b5-spec-2026-09-28.md.

    Target: docker-compose.yml:48-50 — the two lines are adjacent and only the second is bound:

    ports:
      - "${AUTOBOT_REDIS_PORT:-6379}:6379"          # every interface
      - "127.0.0.1:8001:8001"  # RedisInsight UI (localhost only)

    Change: bind the data port the way the neighbour already binds the UI, and where a non-loopback bind is genuinely needed make the address a variable with a loopback default rather than an empty prefix.

    Test file: repo_tests/compose_ports_are_bound_17500_test.py (new — there is no compose guard today). Copy repo_tests/chromadb_bind_not_hardcoded_15317_test.py rather than starting fresh: it already carries the wildcard-bind vocabulary (_WILDCARD_BIND_VALUES = {"0.0.0.0", "::", ""}) and both-directions negative controls, and it is the same property one layer down.

    Assertion: no ports: entry publishes without an address prefix, except entries on an explicit allow-list with a recorded reason.
    Mutation: drop 127.0.0.1: from the RedisInsight line → red. Negative control: an allow-listed public entry must pass, or the guard cannot describe a deliberate 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