Django's admin login is on the open internet at /admin/, and nothing throttles it #116

Closed
opened 2026-09-07 19:24:14 +00:00 by tiagoagueda · 1 comment
Owner

Found by

Verifying the 0.3.0 build deployed to the public test instance. Observed against the live
site rather than read out of the source:

$ curl -sS -o /dev/null -w "%{http_code} -> %{redirect_url}" https://postulo.tiagoagueda.com/admin/
302 -> https://postulo.tiagoagueda.com/admin/login/?next=/admin/

$ curl -sS https://postulo.tiagoagueda.com/admin/login/ | grep -oiE '<title>[^<]*|name="username"|name="password"'
<title>Log in | Django site admin
name="username"
name="password"

A username-and-password form, from Django's own admin, on the open internet.

Why this is worth an issue rather than a note

The code already knows. config/settings/base.py:

# The admin is a small attack surface worth moving off a guessable path.
POSTULO_ADMIN_URL = env("POSTULO_ADMIN_URL", default="admin/")

The comment states the reasoning and the default then picks the guessable path. Every
instance that does not set the variable — which is every instance whose operator did not
read that line — publishes /admin/.

allauth's rate limits do not cover it. #112 records that Postulo inherits allauth's
defaults, and they are good ones: login_failed 10/m/ip, 5/300s/key. Those apply to
allauth's views. django.contrib.admin has a login view of its own, and nothing
throttles it. So the one credential form on the instance with no attempt limiting is the one
that reaches every table directly, and it is at the first path anybody would try.

And the application does not need it. Postulo has its own Server settings — people,
sign-in policy, plugins, email, logs, defaults. The admin is a developer's convenience, and
on a self-hosted instance it is mostly a second, less careful way into the same data.

Worth deciding rather than patching

Three ways, and they are not equivalent:

  1. Do not mount it at all unless asked. POSTULO_ADMIN_URL empty means no admin. An
    operator who wants it opts in and chooses a path in the same breath. This is the one that
    matches what the application actually offers, and it is the one I would pick.
  2. Keep it, but require the variable — refuse to start with the default, as #111 proposes
    for a weak SECRET_KEY. Turns a silent exposure into a decision.
  3. Keep the path and throttle it, sharing the cache allauth already uses. Fixes the brute
    force and leaves a second door into the data.

Whichever, wiki/Hardening.md should say what was chosen; it currently says nothing about
the admin.

While I was there: the edge rewrites a security header

Not the same issue and not worth its own, but it belongs with the evidence. What the
application sends, and what a visitor receives:

origin (gunicorn, inside the container):  X-Frame-Options: DENY
edge   (through traefik):                 X-Frame-Options: SAMEORIGIN

X_FRAME_OPTIONS = "DENY" is set in settings. Traefik replaces it. The impact is small —
frame-ancestors 'none' in the CSP covers every browser that matters — but it is a header
the application sets and nobody receives, and no source-level test can catch that. If
tests/security/ grows a check for headers as actually served, this is the case it should
be written against.

Classification

Security. Tier 2: it needs a password to get through, and it is an unthrottled form at a
guessable address on an application whose own documentation says the path should be moved.

## Found by Verifying the 0.3.0 build deployed to the public test instance. Observed against the live site rather than read out of the source: ``` $ curl -sS -o /dev/null -w "%{http_code} -> %{redirect_url}" https://postulo.tiagoagueda.com/admin/ 302 -> https://postulo.tiagoagueda.com/admin/login/?next=/admin/ $ curl -sS https://postulo.tiagoagueda.com/admin/login/ | grep -oiE '<title>[^<]*|name="username"|name="password"' <title>Log in | Django site admin name="username" name="password" ``` A username-and-password form, from Django's own admin, on the open internet. ## Why this is worth an issue rather than a note **The code already knows.** `config/settings/base.py`: ```python # The admin is a small attack surface worth moving off a guessable path. POSTULO_ADMIN_URL = env("POSTULO_ADMIN_URL", default="admin/") ``` The comment states the reasoning and the default then picks the guessable path. Every instance that does not set the variable — which is every instance whose operator did not read that line — publishes `/admin/`. **allauth's rate limits do not cover it.** #112 records that Postulo inherits allauth's defaults, and they are good ones: `login_failed 10/m/ip, 5/300s/key`. Those apply to `allauth`'s views. `django.contrib.admin` has a **login view of its own**, and nothing throttles it. So the one credential form on the instance with no attempt limiting is the one that reaches every table directly, and it is at the first path anybody would try. **And the application does not need it.** Postulo has its own *Server settings* — people, sign-in policy, plugins, email, logs, defaults. The admin is a developer's convenience, and on a self-hosted instance it is mostly a second, less careful way into the same data. ## Worth deciding rather than patching Three ways, and they are not equivalent: 1. **Do not mount it at all unless asked.** `POSTULO_ADMIN_URL` empty means no admin. An operator who wants it opts in and chooses a path in the same breath. This is the one that matches what the application actually offers, and it is the one I would pick. 2. **Keep it, but require the variable** — refuse to start with the default, as #111 proposes for a weak `SECRET_KEY`. Turns a silent exposure into a decision. 3. **Keep the path and throttle it**, sharing the cache allauth already uses. Fixes the brute force and leaves a second door into the data. Whichever, `wiki/Hardening.md` should say what was chosen; it currently says nothing about the admin. ## While I was there: the edge rewrites a security header Not the same issue and not worth its own, but it belongs with the evidence. What the application sends, and what a visitor receives: ``` origin (gunicorn, inside the container): X-Frame-Options: DENY edge (through traefik): X-Frame-Options: SAMEORIGIN ``` `X_FRAME_OPTIONS = "DENY"` is set in settings. Traefik replaces it. The impact is small — `frame-ancestors 'none'` in the CSP covers every browser that matters — but it is a header the application sets and nobody receives, and **no source-level test can catch that**. If `tests/security/` grows a check for headers as actually served, this is the case it should be written against. ## Classification Security. Tier 2: it needs a password to get through, and it is an unthrottled form at a guessable address on an application whose own documentation says the path should be moved.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 19:24:14 +00:00
Author
Owner

Fixed in 84c98ef, taking option 1 plus option 3: not mounted unless asked for, and throttled when it is.

Off by default. POSTULO_ADMIN_URL is empty and config/urls.py adds nothing when it is. /admin/, /admin/login/ and /django-admin/ all 404 on a stock instance, and there is a test asserting exactly that — the finding written back as the thing that must stay true.

This removes /admin/ from instances that have one, which is the point rather than a side effect. It is the first line of the CHANGELOG entry, not a note at the end.

Throttled, through allauth's own limiter rather than a second scheme. app_settings.RATE_LIMITS does ret.update(rls), so ACCOUNT_RATE_LIMITS = {"admin_login": "10/m/ip,5/300s/key"} adds a key without replacing allauth's defaults — there is a test asserting login_failed survives, because "I added a dict and silently replaced the rate limits" is exactly the kind of thing that would not announce itself. The site is installed through AdminConfig.default_site, the documented hook, rather than by reaching into admin.site; I checked under production settings that admin.site really resolves to ThrottledAdminSite and not to Django's, since the lazy proxy reports its own class name and would have hidden a mistake there.

consume ignores GET, so reading the form is not an attempt — a test loads it fifteen times and gets fifteen 200s, and another confirms a correct password still works when nobody has been guessing. A limit that locks out the person it protects is worse than none.

Two things found while doing it. A path written without its trailing slash produced a URL nobody could reach and no error saying why; values are tidied where they are read (/back-office/, back-office, and back-office/ all mount the same place). And Server settings → Overview linked to the admin as "the escape hatch" — with nothing mounted, reverse("admin:index") raises, so the Overview page would have failed on a link at the bottom of it. It now says there is no admin and which variable turns one on.

The header rewrite you flagged is in wiki/Hardening.md as an instruction rather than a note: check that your proxy passes the headers through, because Traefik replaces X-Frame-Options: DENY with SAMEORIGIN and pins HSTS to a year. frame-ancestors 'none' covers the framing so it is not a hole, and — as the issue says — no source-level test can catch it, so what the page can honestly offer is curl -sSI against your own edge. I did not invent a served-headers test suite for it; that needs a live instance and is a different piece of work.

Hardening.md also gained the admin section it did not have, and Configuration.md, Accounts-and-invitations.md, Troubleshooting.md and .env.example were all still describing the old default.

One consequence for the test instance: ragnar's .env sets no POSTULO_ADMIN_URL, so its /admin/ will disappear at the next deployment. Say the word and I will set a path there before deploying, or leave it off — Server settings covers everything that instance is used for.

Fixed in 84c98ef, taking **option 1 plus option 3**: not mounted unless asked for, and throttled when it is. **Off by default.** `POSTULO_ADMIN_URL` is empty and `config/urls.py` adds nothing when it is. `/admin/`, `/admin/login/` and `/django-admin/` all 404 on a stock instance, and there is a test asserting exactly that — the finding written back as the thing that must stay true. **This removes `/admin/` from instances that have one**, which is the point rather than a side effect. It is the first line of the CHANGELOG entry, not a note at the end. **Throttled, through allauth's own limiter rather than a second scheme.** `app_settings.RATE_LIMITS` does `ret.update(rls)`, so `ACCOUNT_RATE_LIMITS = {"admin_login": "10/m/ip,5/300s/key"}` adds a key without replacing allauth's defaults — there is a test asserting `login_failed` survives, because "I added a dict and silently replaced the rate limits" is exactly the kind of thing that would not announce itself. The site is installed through `AdminConfig.default_site`, the documented hook, rather than by reaching into `admin.site`; I checked under production settings that `admin.site` really resolves to `ThrottledAdminSite` and not to Django's, since the lazy proxy reports its own class name and would have hidden a mistake there. `consume` ignores GET, so **reading the form is not an attempt** — a test loads it fifteen times and gets fifteen 200s, and another confirms a correct password still works when nobody has been guessing. A limit that locks out the person it protects is worse than none. **Two things found while doing it.** A path written without its trailing slash produced a URL nobody could reach and no error saying why; values are tidied where they are read (`/back-office/`, `back-office`, and ` back-office/ ` all mount the same place). And *Server settings → Overview* linked to the admin as "the escape hatch" — with nothing mounted, `reverse("admin:index")` raises, so the Overview page would have failed on a link at the bottom of it. It now says there is no admin and which variable turns one on. **The header rewrite** you flagged is in `wiki/Hardening.md` as an instruction rather than a note: check that your proxy passes the headers through, because Traefik replaces `X-Frame-Options: DENY` with `SAMEORIGIN` and pins HSTS to a year. `frame-ancestors 'none'` covers the framing so it is not a hole, and — as the issue says — no source-level test can catch it, so what the page can honestly offer is `curl -sSI` against your own edge. I did not invent a served-headers test suite for it; that needs a live instance and is a different piece of work. Hardening.md also gained the admin section it did not have, and `Configuration.md`, `Accounts-and-invitations.md`, `Troubleshooting.md` and `.env.example` were all still describing the old default. **One consequence for the test instance:** ragnar's `.env` sets no `POSTULO_ADMIN_URL`, so its `/admin/` will disappear at the next deployment. Say the word and I will set a path there before deploying, or leave it off — *Server settings* covers everything that instance is used for.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#116
No description provided.