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.
: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.
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.
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.
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.
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.
# 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 |
|---|---|---|
| Ruby | start / end of any line, always | \A … \z |
| JavaScript | start / end of string, unless the m flag is set | ^ … $ without m |
| Python | start / 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 |
| Java | matches() checks the whole string | matches() 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.
| pattern | behaves like | result |
|---|
^/$ 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.
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
current_user, or use Pundit. More in section 09.