Use of Non-Canonical URL Paths for Authorization Decisions
Description
Use of Non-Canonical URL Paths for Authorization Decisions occurs when security controls match URL paths without normalizing them first. Different URL representations (path traversal sequences, URL encoding, case variations, trailing slashes) can refer to the same resource but bypass access controls that perform simple string matching on paths.
Risk
Authorization bypass by using alternate URL encodings. Path traversal sequences evade security filters. Case-sensitive matching on case-insensitive filesystems allows bypass. Double encoding bypasses single-decode filters. Directory traversal through URL manipulation. Access to protected resources through URL normalization differences.
Solution
Canonicalize URLs before authorization decisions. Normalize path traversal sequences (/../, /./). Decode URL encoding before matching. Handle case sensitivity consistently. Remove duplicate slashes and trailing slashes. Use framework-provided path normalization. Apply authorization after URL resolution.
Common Consequences
| Impact | Details |
|---|---|
| Authorization | Scope: Bypass Access controls circumvented via URL manipulation. |
| Confidentiality | Scope: Data Exposure Protected resources accessed. |
| Integrity | Scope: Unauthorized Actions Protected operations executed. |
Example Code + Solution Code
Vulnerable Code
// VULNERABLE: String matching without normalization
@WebFilter("/*")
public class VulnerableAuthFilter implements Filter {
private static final Set<String> PROTECTED_PATHS = Set.of(
"/admin",
"/admin/users",
"/api/internal"
);
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String path = request.getRequestURI();
// VULNERABLE: Direct string matching
if (PROTECTED_PATHS.contains(path)) {
if (!isAuthenticated(request)) {
((HttpServletResponse) res).sendError(403);
return;
}
}
// Bypasses:
// /admin/ (trailing slash)
// /admin/../admin (path traversal)
// /ADMIN (case variation)
// /admin%2Fusers (encoded slash)
// //admin (double slash)
// /./admin (dot segment)
chain.doFilter(req, res);
}
}
// VULNERABLE: Starts-with matching
public class VulnerableApiFilter {
public boolean isProtectedPath(String path) {
// VULNERABLE: Simple prefix match
return path.startsWith("/api/admin");
// Bypass: /api/admin/../public
// The path starts with /api/admin but resolves to /api/public
}
}
// VULNERABLE: Regex without normalization
public class VulnerableRegexFilter {
private Pattern adminPattern = Pattern.compile("^/admin/.*");
public boolean isAdminPath(String path) {
// VULNERABLE: Regex on non-normalized path
return adminPattern.matcher(path).matches();
// Bypass: /admin%2F..%2Fpublic
// URL-encoded but not decoded before matching
}
}
# VULNERABLE: Python path matching
from flask import Flask, request, abort
app = Flask(__name__)
PROTECTED_PATHS = {'/admin', '/admin/', '/api/internal'}
@app.before_request
def check_auth_vulnerable():
path = request.path
# VULNERABLE: Direct string comparison
if path in PROTECTED_PATHS:
if not is_authenticated():
abort(403)
# Bypasses:
# /Admin (case)
# /admin// (double slash)
# /admin/.. (parent directory)
# /%61dmin (encoded 'a')
# VULNERABLE: Prefix matching
def is_protected_vulnerable(path):
# VULNERABLE: Simple string matching
protected_prefixes = ['/admin', '/internal']
return any(path.startswith(prefix) for prefix in protected_prefixes)
# Bypass: /admin/../public
# VULNERABLE: File path serving
@app.route('/files/<path:filepath>')
def serve_file_vulnerable(filepath):
# VULNERABLE: No path normalization
base = '/var/www/files/'
# Attacker: /files/../../etc/passwd
# Attacker: /files/private%2fsecret.txt
if filepath.startswith('private/'):
if not is_admin():
abort(403)
return send_from_directory(base, filepath)
// VULNERABLE: Node.js Express path matching
const express = require('express');
const app = express();
const PROTECTED_PATHS = ['/admin', '/api/internal', '/dashboard'];
// VULNERABLE: Direct string matching
app.use((req, res, next) => {
const path = req.path;
if (PROTECTED_PATHS.includes(path)) {
if (!req.session.user) {
return res.status(403).send('Forbidden');
}
}
next();
});
// Bypasses:
// /admin/ (trailing slash)
// /ADMIN (case on case-insensitive systems)
// /admin/../admin
// /%61dmin (URL encoded)
// VULNERABLE: Regex matching
app.use((req, res, next) => {
// VULNERABLE: Regex on raw path
if (/^\/admin\//.test(req.path)) {
if (!req.session.isAdmin) {
return res.status(403).send('Admin only');
}
}
next();
});
// VULNERABLE: Static file serving with path
app.get('/files/:filename', (req, res) => {
const filename = req.params.filename;
// VULNERABLE: No normalization
if (filename.includes('secret')) {
if (!req.session.isAdmin) {
return res.status(403).send('Forbidden');
}
}
// Attacker: /files/..%2fsecret%2fdata.txt
res.sendFile(`/data/${filename}`);
});
<?php
// VULNERABLE: Direct path matching
$protectedPaths = ['/admin', '/admin/', '/internal'];
$requestPath = $_SERVER['REQUEST_URI'];
// VULNERABLE: Simple in_array check
if (in_array($requestPath, $protectedPaths)) {
if (!isAuthenticated()) {
http_response_code(403);
die('Forbidden');
}
}
// Bypasses:
// /admin?foo=bar (query string)
// /Admin (case)
// /admin/../admin
// /%61dmin (encoded)
// VULNERABLE: String comparison for admin check
function isAdminPathVulnerable($path) {
// VULNERABLE: strpos without normalization
return strpos($path, '/admin') === 0;
}
// Bypass: /admin/../public
// VULNERABLE: File access with path
$file = $_GET['file'];
$basePath = '/var/www/uploads/';
// VULNERABLE: Path not normalized
$fullPath = $basePath . $file;
if (strpos($file, 'private/') === 0) {
if (!isAdmin()) {
die('Access denied');
}
}
// Attacker: ?file=../../../etc/passwd
// Or: ?file=private/../public/data.txt (bypasses check but accesses public)
readfile($fullPath);
?>
Fixed Code
// SAFE: Canonical path matching
@WebFilter("/*")
public class SafeAuthFilter implements Filter {
private static final Set<String> PROTECTED_PATHS = Set.of(
"/admin",
"/admin/users",
"/api/internal"
);
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String path = canonicalizePath(request.getRequestURI());
// Check with normalized path
if (isProtectedPath(path)) {
if (!isAuthenticated(request)) {
((HttpServletResponse) res).sendError(403);
return;
}
}
chain.doFilter(req, res);
}
private String canonicalizePath(String path) {
if (path == null) return "/";
// Decode URL encoding
try {
path = URLDecoder.decode(path, "UTF-8");
} catch (UnsupportedEncodingException e) {
return "/";
}
// Normalize using URI
try {
URI uri = new URI(path).normalize();
path = uri.getPath();
} catch (URISyntaxException e) {
return "/";
}
// Remove trailing slash (except for root)
if (path.length() > 1 && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
// Convert to lowercase for case-insensitive matching
path = path.toLowerCase();
// Remove double slashes
path = path.replaceAll("/+", "/");
return path;
}
private boolean isProtectedPath(String normalizedPath) {
// Check exact match
if (PROTECTED_PATHS.contains(normalizedPath)) {
return true;
}
// Check prefix match (for sub-paths)
for (String protectedPath : PROTECTED_PATHS) {
if (normalizedPath.startsWith(protectedPath + "/")) {
return true;
}
}
return false;
}
}
// SAFE: Using servlet path resolution
public class SafePathMatcher {
public boolean isProtected(HttpServletRequest request) {
// Use getServletPath() which is already normalized
String servletPath = request.getServletPath().toLowerCase();
// Or use Spring's AntPathMatcher with normalized paths
AntPathMatcher matcher = new AntPathMatcher();
return matcher.match("/admin/**", servletPath);
}
}
# SAFE: Python with path normalization
from flask import Flask, request, abort
from urllib.parse import unquote
import os
import re
app = Flask(__name__)
PROTECTED_PATHS = {'/admin', '/api/internal'}
def canonicalize_path(path):
"""Normalize URL path for secure matching."""
if not path:
return '/'
# URL decode
path = unquote(path)
# Normalize path (resolve . and ..)
# Use posixpath for URL paths
import posixpath
path = posixpath.normpath(path)
# Ensure starts with /
if not path.startswith('/'):
path = '/' + path
# Remove trailing slash (except root)
if len(path) > 1 and path.endswith('/'):
path = path[:-1]
# Lowercase for case-insensitive matching
path = path.lower()
# Remove double slashes
path = re.sub(r'/+', '/', path)
return path
@app.before_request
def check_auth_safe():
# Normalize path before checking
normalized_path = canonicalize_path(request.path)
for protected in PROTECTED_PATHS:
if normalized_path == protected or normalized_path.startswith(protected + '/'):
if not is_authenticated():
abort(403)
# SAFE: File serving with proper validation
@app.route('/files/<path:filepath>')
def serve_file_safe(filepath):
base = os.path.realpath('/var/www/files/')
# Decode and normalize
filepath = unquote(filepath)
# Build full path and resolve
full_path = os.path.realpath(os.path.join(base, filepath))
# Verify path is within base directory
if not full_path.startswith(base + os.sep):
abort(403, 'Path traversal detected')
# Check permissions after normalization
relative_path = os.path.relpath(full_path, base)
if relative_path.startswith('private/'):
if not is_admin():
abort(403)
return send_from_directory(base, relative_path)
// SAFE: Node.js with path normalization
const express = require('express');
const path = require('path');
const app = express();
const PROTECTED_PATHS = new Set(['/admin', '/api/internal', '/dashboard']);
function canonicalizePath(urlPath) {
if (!urlPath) return '/';
// Decode URL encoding
urlPath = decodeURIComponent(urlPath);
// Normalize path
urlPath = path.posix.normalize(urlPath);
// Ensure starts with /
if (!urlPath.startsWith('/')) {
urlPath = '/' + urlPath;
}
// Remove trailing slash (except root)
if (urlPath.length > 1 && urlPath.endsWith('/')) {
urlPath = urlPath.slice(0, -1);
}
// Lowercase
urlPath = urlPath.toLowerCase();
// Remove double slashes
urlPath = urlPath.replace(/\/+/g, '/');
return urlPath;
}
// SAFE: Middleware with canonicalization
app.use((req, res, next) => {
const normalizedPath = canonicalizePath(req.path);
// Check if protected
if (PROTECTED_PATHS.has(normalizedPath) ||
Array.from(PROTECTED_PATHS).some(p => normalizedPath.startsWith(p + '/'))) {
if (!req.session.user) {
return res.status(403).send('Forbidden');
}
}
next();
});
// SAFE: File serving with path validation
app.get('/files/:filename(*)', (req, res) => {
const baseDir = path.resolve('/data/files');
const filename = decodeURIComponent(req.params.filename);
// Resolve to absolute path
const fullPath = path.resolve(baseDir, filename);
// Verify within base directory
if (!fullPath.startsWith(baseDir + path.sep)) {
return res.status(403).send('Invalid path');
}
// Check permissions after normalization
const relativePath = path.relative(baseDir, fullPath);
if (relativePath.startsWith('secret')) {
if (!req.session.isAdmin) {
return res.status(403).send('Forbidden');
}
}
res.sendFile(fullPath);
});
Exploited in the Wild
Admin Panel Bypass
URL encoding bypasses for admin access.
WAF Evasion
Path normalization differences bypassing WAF rules.
Path Traversal
Directory traversal through URL manipulation.
Tools to test/exploit
-
Burp Suite — URL manipulation.
-
URL encoding/fuzzing tools.
-
Path traversal payloads.
CVE Examples
-
CVE-2020-17530: Apache Struts path bypass.
-
Numerous URL normalization bypass CVEs.
References
-
MITRE. "CWE-647: Use of Non-Canonical URL Paths for Authorization Decisions." https://cwe.mitre.org/data/definitions/647.html
-
OWASP. "Path Traversal."