guest@websec: ~/websec/18-intranet-and-admin — zsh — 100×40 by SaKKo ENTH code:

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:

sim: admin hardeningan attacker gets an agent's session; how far can they go?

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.
Nothing built in. Admin gems escape output, but roles, IP limits, re-auth and audit logs are ours (try 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).