guest@websec: ~/websec/08-user-management — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 08-user-management

08 · User Management

Accounts attract simple attacks: guessing passwords, swapping someone's email, changing an ID in the URL. Plus two sneaky leaks: logs and regexes.

The generator and Devise handle the basics. Devise can also lock accounts (:lockable). Neither adds MFA for you.

8.1 Brute-forcing accounts

Attackers don't guess randomly. They try common passwords and ones leaked from other sites (credential stuffing). Most passwords fall within a few thousand tries.

Defenses:

  • Generic errors on login and "forgot password".
  • Rate limits per IP and per account. Rails: rate_limit to: 10, within: 3.minutes.
  • CAPTCHA after a few failures.
  • MFA, so a right password still isn't enough.
sim: dictionary attack vs rate limitingthe real password is #8,114 on the attacker's list
class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create,
             with: -> { redirect_to new_session_url, alert: "Try again later." }
end

# config/initializers/rack_attack.rb: per-account limit across all IPs
Rack::Attack.throttle("logins/email", limit: 20, period: 1.hour) do |req|
  req.params["email_address"].to_s.downcase.presence if req.path == "/session" && req.post?
end
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
  windowMs: 3 * 60 * 1000, limit: 10,                // 10 per 3 minutes per IP
  standardHeaders: "draft-7", legacyHeaders: false,
  message: "Too many attempts. Try again in a few minutes.",
});
app.post("/login", loginLimiter, loginHandler);
// add a per-account counter (e.g. in Redis keyed by email) to stop botnets
// that rotate IPs, and require a CAPTCHA once it passes a threshold
# Flask: pip install Flask-Limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(get_remote_address, app=app, storage_uri="redis://localhost")

@app.post("/login")
@limiter.limit("10 per 3 minutes")
def login(): ...

# Django: django-axes locks out by username + IP after N failures
# INSTALLED_APPS += ["axes"]; AXES_FAILURE_LIMIT = 5
rate_limit needs a shared cache in production, or each server counts alone. For per-account limits, add rack-attack.

8.2 Account hijacking

Someone grabs a session for a minute (unlocked laptop, XSS). They'll try to make it permanent by changing the password or email.

Passwords

Ask for the current password before setting a new one.

Email

Same for email, since a new email means they control password resets. Also notify the old address.

Other

Treat phone numbers, MFA devices and forwarding rules the same way.

sim: attacker with a stolen sessiontry to take the account over
Devise asks for the current password already. In our own settings code, check user.authenticate(params[:current_password]).

8.3 CAPTCHAs

Two flavors:

  • Visible (reCAPTCHA, Turnstile): ask humans to prove it. Works, but annoys people.
  • Invisible: a hidden honeypot field. People don't see it; dumb bots fill it in.

Neither replaces rate limiting.

sim: honeypot fieldsubmit as a human, then as a bot

what the human sees

the actual form HTML

<input name="name">
<input name="comment">
<!-- hidden from humans -->
<div style="position:absolute;left:-9999px"
     aria-hidden="true">
  <input name="website" tabindex="-1"
         autocomplete="off">
</div>
<input type="hidden" name="ts" value="…">

8.4 Logging

Frameworks log request params. That puts passwords and card numbers in plain text in log files, which get shared and kept for months.

Mask sensitive params before they're logged.

sim: request log filteredit the filter list
# config/initializers/filter_parameter_logging.rb (generated)
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc,
  :card_number, :national_id                  # add your own
]
// pino (common Express logger) supports redaction paths
const logger = require("pino")({
  redact: { paths: ["req.body.password", "req.body.card_number",
                    "req.headers.authorization", "req.headers.cookie", "*.token"],
            censor: "[FILTERED]" },
});
# Django: mark views so error reports hide values
from django.views.decorators.debug import sensitive_post_parameters

@sensitive_post_parameters("password", "card_number")
def signup(request): ...

# Generic Python: a logging.Filter that scrubs known keys
class Redact(logging.Filter):
    KEYS = ("passw", "secret", "token", "card")
    def filter(self, record):
        if isinstance(record.args, dict):
            record.args = {k: "[FILTERED]" if any(s in k.lower() for s in self.KEYS) else v
                           for k, v in record.args.items()}
        return True
filter_parameters already masks passwords, tokens, keys and more. Add your own fields, like card numbers.

8.5 Regular expressions

"Start" and "end" anchors mean different things in different languages:

language^ / $ mean…whole-string anchors
Rubystart / end of any line, always\A … \z
JavaScriptstart / end of string, unless the m flag is set^ … $ without m
Pythonstart / end of string, but $ also matches before a trailing \n; with re.M, any line\A … \Z, or re.fullmatch
PHP (PCRE)same as Python: $ allows a trailing newline unless D flag\A … \z
Javamatches() checks the whole stringmatches() or \A…\z

The classic bug: a URL check passes because the second line looks fine, while line one is javascript:. Anchor to the whole string and test with newlines.

sim: anchors and newlinesJavaScript regex engine, live
patternbehaves likeresult
Rails' format validator refuses ^/$ unless you say multiline: true. Regexes elsewhere get no such check.

8.6 Privilege escalation

One of the most common bugs on the web: change /projects/1042 to 1043 and see someone else's project. It's called IDOR (Insecure Direct Object Reference).

Fix: always look things up through the current user. current_user.projects.find(id), not Project.find(id).

And remember: hidden fields and front-end checks aren't security. Anyone can edit what their browser sends.

sim: edit the IDyou are user #7 (dana)
GET /projects/

Project.find(id)

current_user.projects.find(id)

# VULNERABLE: any logged-in user can read any project
@project = Project.find(params[:id])

# SAFE: scope through the current user; others get 404 (RecordNotFound)
@project = Current.user.projects.find(params[:id])

# shared resources: a policy object (Pundit)
@project = Project.find(params[:id])
authorize @project            # ProjectPolicy#show? decides
// VULNERABLE: any logged-in user can read any project
const p = await db.query("SELECT * FROM projects WHERE id = $1", [req.params.id]);

// SAFE: owner is part of the lookup; others get a 404
const p = await db.query(
  "SELECT * FROM projects WHERE id = $1 AND owner_id = $2",
  [req.params.id, req.user.id]);
if (!p.rows.length) return res.sendStatus(404);
# VULNERABLE (Django)
project = Project.objects.get(pk=pk)

# SAFE: scope through the current user
project = get_object_or_404(Project, pk=pk, owner=request.user)
# or: request.user.projects.get(pk=pk) via the related name
No authorization built in. Scope through current_user, or use Pundit. More in section 09.