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.
| validator | decision | where 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)
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.
blocklist: remove "../" once
allowlist: keep [A-Za-z0-9._-]
best: random name
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: nosniffso 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.
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
Two reliable fixes:
- Resolve, then check the final path is still inside your folder.
- 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]).