Django's admin login is on the open internet at /admin/, and nothing throttles it #116
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#116
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
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 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 toallauth's views.django.contrib.adminhas a login view of its own, and nothingthrottles 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:
POSTULO_ADMIN_URLempty means no admin. Anoperator 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.
for a weak
SECRET_KEY. Turns a silent exposure into a decision.force and leaves a second door into the data.
Whichever,
wiki/Hardening.mdshould say what was chosen; it currently says nothing aboutthe 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:
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 headerthe 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 shouldbe 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.
Fixed in
84c98ef, taking option 1 plus option 3: not mounted unless asked for, and throttled when it is.Off by default.
POSTULO_ADMIN_URLis empty andconfig/urls.pyadds 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_LIMITSdoesret.update(rls), soACCOUNT_RATE_LIMITS = {"admin_login": "10/m/ip,5/300s/key"}adds a key without replacing allauth's defaults — there is a test assertinglogin_failedsurvives, 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 throughAdminConfig.default_site, the documented hook, rather than by reaching intoadmin.site; I checked under production settings thatadmin.sitereally resolves toThrottledAdminSiteand not to Django's, since the lazy proxy reports its own class name and would have hidden a mistake there.consumeignores 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, andback-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.mdas an instruction rather than a note: check that your proxy passes the headers through, because Traefik replacesX-Frame-Options: DENYwithSAMEORIGINand 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 iscurl -sSIagainst 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.mdand.env.examplewere all still describing the old default.One consequence for the test instance: ragnar's
.envsets noPOSTULO_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.