Skip to content

The dashboard session cookie takes its Secure flag from the broker's TLS setting #50

Description

@HectorIFC

Context

The dashboard decides whether to mark its session cookie Secure from a setting that describes a different listener.

Malachi.Dashboard.secure_cookie_flag/0 (lib/malachi/dashboard.ex:347-349) reads :enable_tls, which is the broker's TLS switch, resolved in config/runtime.exs:89-95 from MALACHI_ENABLE_TLS and MALACHI_REQUIRE_TLS. The dashboard itself never serves TLS: init/1 (:32) listens with :gen_tcp.listen and there is no :ssl path anywhere in the module. So the flag answers a question about port 4040 and is applied to a cookie issued on port 4041, which is always plain HTTP.

The comment above the function shows the intent was right: it says Secure is withheld on a plain-HTTP dev server "where the browser would then silently drop it". The signal chosen just does not carry that information, and it is wrong in both directions.

Broker TLS on. The cookie is marked Secure and handed out over plain HTTP. Browsers reject a Secure cookie set from a non-trustworthy origin, so it is never stored and login silently fails: the form posts, the server answers 200, and nothing happens. http://localhost and http://127.0.0.1 are trustworthy origins, so a local operator sees none of this; anyone reaching the dashboard by hostname or IP sees a login that cannot succeed and no error explaining why.

Broker TLS off, dashboard behind an HTTPS-terminating proxy. The cookie is issued without Secure, so a browser will also send it over a plain-HTTP origin. That is the exposure Secure exists to prevent, in the one deployment shape where the dashboard is actually reachable securely.

Reproduction

Built from main, with a self-signed pair mounted:

docker run -d -p 127.0.0.1:14041:4041 \
  -e MALACHI_ADMIN_PASS=... \
  -e MALACHI_TLS_CERTFILE=/app/priv/cert/server.crt \
  -e MALACHI_TLS_KEYFILE=/app/priv/cert/server.key \
  -v <certs>:/app/priv/cert:ro <image>
$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:14041/health
200
$ curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1:14041/health
000
$ curl -i -X POST http://127.0.0.1:14041/login -H 'Content-Type: application/json' -d '{...}'
HTTP/1.1 200 OK
Set-Cookie: malachi_token=...; HttpOnly; Path=/; SameSite=Strict; Secure

Plain HTTP answers, HTTPS does not connect, and the cookie comes back Secure. curl stores it with the secure flag set and still sends it back over HTTP, which is curl being lenient rather than evidence that this works: the browser rule is what matters here, and it is the reason the flag was made conditional in the first place.

Plan

A. Derive the flag from the request, honouring a forwarded-proto header. Mark the cookie Secure when the request arrived over HTTPS, which for the only shape that can be HTTPS today means trusting X-Forwarded-Proto from a proxy in front. Correct in both directions and needs no new setting, but trusting a header requires deciding when it may be trusted; taken from an arbitrary client it is an attacker-controlled input, so it needs to be gated on an explicit opt-in rather than honoured by default.

B. An explicit operator setting. Something like MALACHI_DASHBOARD_SECURE_COOKIE, defaulting off, that says how the dashboard is actually reached. Blunt, honest, and it stops deriving one listener's property from another's. The cost is one more thing to get wrong, and it is wrong silently.

C. Give the dashboard its own TLS listener. Then the flag follows from a fact this module owns, and the reverse-proxy requirement in the Docker documentation goes away. Much the largest change, and it duplicates configuration the broker listener already has, so it deserves its own discussion rather than being folded into a cookie fix.

D. Do nothing, and document it. The composes bind 4041 to loopback, where browsers treat the origin as trustworthy, so the broken-login half does not bite anyone following the documentation. The downgrade half still does, in any proxied deployment.

Risks and open questions

  • Whichever is chosen, the failure is silent today and should stop being: a login that cannot succeed answers 200 and says nothing, which is what made this hard to notice.
  • X-Forwarded-Proto must not be trusted by default. If option A is taken, the gate on it is the security-relevant part of the change, not the cookie flag.
  • Worth deciding at the same time whether the Secure flag and the dashboard's other cookie attributes should be settable together, since they describe the same deployment fact.

Verification

  • A test that pins the flag against the intended signal rather than :enable_tls, covering both directions: secure transport gets Secure, plain transport does not, with the broker's own TLS setting varied independently to show it no longer participates.
  • The cookie-clearing redirects share secure_cookie_flag/0 and must stay in step, which is why they share it; the test should cover one of those too.
  • Full suite, mix credo --strict, mix dialyzer, mix docs --warnings-as-errors.

Found while verifying a CodeRabbit finding on PR #48 about the Docker TLS example publishing the dashboard port, which is the same underlying fact seen from the outside: 4041 has no TLS of its own.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecurity

Projects

Status
Done

Relationships

None yet

Development

No branches or pull requests

Issue actions