guest@websec: ~/websec/10-redirection-and-files — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 10-redirection-and-files

10 · Redirection and Files

Both turn user input into a place: a URL to go to, or a file on disk. Let users pick the place, and attackers will pick one you didn't plan for.

10.1 Redirection

Lots of apps have /login?next=/dashboard. If we redirect to whatever next says, attackers can craft a link that starts on our site and ends on theirs:

https://bank.test/login?next=https://bank-secure-login.evil.test/

The victim sees our domain, logs in for real, then lands on a fake "session expired" page. Perfect phishing.

Fix: only redirect to paths on our own site. Use a real URL parser; string checks are easy to fool.

sim: redirect validator showdowntry the bypasses
payloads:
validatordecisionwhere the browser actually goes
# Rails 7.0+ default: raise_on_open_redirects = true
redirect_to params[:next]                         # raises for foreign hosts
redirect_to params[:next], allow_other_host: true  # opts out: never with user input

# keep "return to" on our own site
redirect_to url_from(params[:next]) || root_path
function safeNext(next) {
  // resolve against our own origin, then require the same origin
  try {
    const u = new URL(next, "https://bank.test");
    if (u.origin !== "https://bank.test") return "/";
    return u.pathname + u.search + u.hash;      // relative, same-site
  } catch { return "/"; }
}
app.get("/after-login", (req, res) => res.redirect(safeNext(req.query.next)));
# Django ships a helper for exactly this
from django.utils.http import url_has_allowed_host_and_scheme

def after_login(request):
    nxt = request.GET.get("next", "/")
    if not url_has_allowed_host_and_scheme(nxt, allowed_hosts={request.get_host()},
                                           require_https=request.is_secure()):
        nxt = "/"
    return redirect(nxt)
Since Rails 7, redirect_to refuses other hosts unless you add allow_other_host: true. Use url_from(params[:next]). Older upgraded apps may still have this off, so grep for it.

10.2 File uploads: filenames

The filename comes from the attacker. Save it as-is and ../../config/master.key lands outside your upload folder.

Don't try to strip "bad" bits; that can create new ones. Allow only safe characters, or better, ignore their filename and use a random one.

sim: sanitizing a filenameblocklist vs allowlist
try:

blocklist: remove "../" once

allowlist: keep [A-Za-z0-9._-]

best: random name

Active Storage already uses random keys. Writing files yourself with original_filename brings the problem back.

Uploads and denial of service

Resizing and converting is slow. Do it in a background job, and cap upload size at the proxy.

10.3 Executable code in uploads

If uploads sit in a public folder, the server might run them, and an uploaded .html or .svg can run JavaScript on our domain.

  • Store uploads outside the web root, or in object storage (S3, GCS) on a separate domain.
  • Serve them with an explicit type and as a download.
  • Send X-Content-Type-Options: nosniff so browsers don't guess (section 11).
  • Validate the real file type by reading magic bytes, not by trusting the extension or the browser-supplied Content-Type.
Active Storage keeps files out of public/, detects the real file type from the file's contents, and serves risky types like HTML and SVG as downloads instead of showing them. It doesn't reject anything, though: validating type and size is our job.

10.4 File downloads and path traversal

Same bug, other direction: a download URL that builds a path from a parameter.

# VULNERABLE
def download
  send_file Rails.root.join("files", params[:name])
end
# GET /download?name=../config/master.key  → serves your master key
// VULNERABLE
app.get("/download", (req, res) =>
  res.sendFile(path.join(__dirname, "files", req.query.name)));
// GET /download?name=../.env   → serves your secrets
# VULNERABLE
@app.get("/download")
def download():
    return send_file(os.path.join("files", request.args["name"]))
# GET /download?name=../.env   → serves your secrets
sim: path resolverbase directory: /srv/app/files
try:

Two reliable fixes:

  1. Resolve, then check the final path is still inside your folder.
  2. Better: no names at all. Download by record ID and check the user may see it.
BASE = Rails.root.join("files").realpath

def download
  path = BASE.join(params[:name].to_s).expand_path
  return head :not_found unless path.to_s.start_with?("#{BASE}/") && path.file?
  send_file path
end

# better: Active Storage record, authorized first
def show
  doc = current_user.documents.find(params[:id])
  redirect_to rails_blob_path(doc.file, disposition: "attachment")
end
const BASE = path.resolve(__dirname, "files") + path.sep;
app.get("/download", (req, res) => {
  const target = path.resolve(BASE, String(req.query.name));
  if (!target.startsWith(BASE)) return res.sendStatus(404);   // escaped the folder
  res.sendFile(target);
});
// Express's res.sendFile(name, { root: BASE }) also rejects "../" escapes.
from pathlib import Path
BASE = Path("files").resolve()

@app.get("/download")
def download():
    target = (BASE / request.args["name"]).resolve()
    if not target.is_relative_to(BASE):          # Python 3.9+
        abort(404)
    return send_file(target)
# Flask's send_from_directory(BASE, name) performs this check for you.
send_file doesn't check paths. Prefer current_user.documents.find(params[:id]).