guest@websec ~ % man 18-intranet-and-admin
18 · Intranet and Admin Security
Admin tools hold the most power, get built fastest, and get reviewed least. Attackers know. Take over one admin and you have every user.
Why admin interfaces are special
Admin screens can do everything, and they display stuff the public typed in: tickets, profiles, filenames. Perfect for XSS.
"Internal" isn't a wall. Staff browsers also read email and browse the web.
18.1 Cross-site scripting in admin tools
One unescaped field in an admin page is enough. The script runs as the admin and can quietly create a new admin.
Same fixes as section 04, applied just as strictly to internal tools.
18.2 Cross-site request forgery against admins
CSRF against an admin can do anything the admin can. Real example: a web page that silently changed people's home router settings.
Same defenses as section 07. Never turn CSRF off "because it's internal".
18.3 Additional precautions
Plan for the day an admin account gets taken:
blast radius
- Roles. Support doesn't need finance powers. Check on the server, every time.
- Separate admin logins with passkeys or security keys.
- Ask again before dangerous actions like exports or role changes.
- VPN or office network only.
- Own subdomain, like
admin.example.com, so public-site XSS can't reach admin cookies. - Audit log + alerts on every privileged action.
pundit and audited).# config/routes.rb: admin only on its own host constraints(host: "admin.example.com") do namespace :admin do resources :users end end class Admin::BaseController < ApplicationController ADMIN_NET = IPAddr.new("10.0.0.0/8") # VPN range (trust your proxy config) before_action { head :forbidden unless ADMIN_NET.include?(request.remote_ip) } before_action :require_admin_session! # separate admin login + MFA content_security_policy { |p| p.default_src :self; p.script_src :self } end class Admin::RolesController < Admin::BaseController before_action -> { require_recent_auth!(5.minutes) } # your helper: re-prompt def update user = User.find(params[:user_id]) authorize user, :change_role? # Pundit policy user.update!(role: params.require(:role)) AuditLog.create!(actor: Current.admin, action: "role_change", target: user) end end
// Express: lock the admin router down in layers const admin = express.Router(); admin.use(requireNetwork(["10.0.0.0/8"])); // VPN range (behind a trusted proxy) admin.use(requireAdminSession); // separate admin login + MFA admin.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"] } })); admin.post("/users/:id/role", requireRole("superadmin"), requireRecentAuth(5 * 60), async (req, res) => { await audit.log(req.admin.id, "role_change", req.params.id, req.body.role); … }); app.use(vhost("admin.example.com", admin)); // separate host
# Django: staff-only views, permissions, and re-auth for sensitive actions from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import permission_required @staff_member_required @permission_required("accounts.change_role", raise_exception=True) @require_recent_auth(minutes=5) # your decorator: redirect to re-auth def change_role(request, user_id): AuditLog.objects.create(actor=request.user, action="role_change", target=user_id) ... # Serve the admin on its own host, e.g. with django-hosts or a separate # deployment, and restrict it at the proxy (nginx allow/deny, IAP, VPN).