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

ImpactDetails
AuthorizationScope: Bypass

Access controls circumvented via URL manipulation.
ConfidentialityScope: Data Exposure

Protected resources accessed.
IntegrityScope: 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

  1. MITRE. "CWE-647: Use of Non-Canonical URL Paths for Authorization Decisions." https://cwe.mitre.org/data/definitions/647.html

  2. OWASP. "Path Traversal."