FlawOpen

flawopen.com/path-traversal/Swift

● CWE-22 · High
FlawOpen Security Research

Path Traversal in Swift (Foundation URL, Vapor)

URL.appendingPathComponent keeps ../ segments for the file system to follow, a hasPrefix check on standardizedFileURL without a trailing slash accepts /srv/uploads-old and ignores symbolic links, and a file name taken from a server response can climb out of an app's Documents folder. How each mistake exposes files from a Vapor server or an iOS or macOS app, and how resolvingSymlinksInPath, a slash-terminated prefix check and lastPathComponent stop it.

💡 Plain English Explainer (ELI5)

Imagine an iPhone photo album where every download belongs in one private folder. URL.appendingPathComponent() adds a name, but ../ can walk out of that folder. standardizedFileURL cleans the spelling; it does not follow a symbolic-link shortcut, and hasPrefix on /srv/uploads also accepts the neighboring /srv/uploads-old. Resolve both base and target with resolvingSymlinksInPath(), then require hasPrefix(base.path + "/"). For a server-supplied filename, keep only lastPathComponent and write with .withoutOverwriting. Vapor FileMiddleware is the safer choice for public static files.

Core Concepts & Subsystem Terms

appendingPathComponent keeps ..
uploadDir.appendingPathComponent("../../etc/hostname") keeps the .. segments, and the kernel resolves them when the file is opened. An absolute name is appended rather than replacing the base, so .. is the attack.
standardizedFileURL and hasPrefix
standardizedFileURL removes . and .. lexically, but path.hasPrefix("/srv/uploads") compares characters, so /srv/uploads-old/secret.txt passes, and a symbolic link inside the folder is not followed at all.
resolvingSymlinksInPath()
Resolves symbolic links in the existing parts of the path. Resolve the base the same way, then require the target to start with the base path plus /, so links cannot lead outside the folder.
lastPathComponent for server-supplied names
A download's name from a Content-Disposition header or JSON field can contain ../. URL(fileURLWithPath: name).lastPathComponent keeps only the final component; writing with .withoutOverwriting refuses to replace an existing file.
Vapor FileMiddleware
Serves files from the Public directory and rejects request paths that contain ../. Use it for static assets instead of building file paths from req.parameters or req.query yourself.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

An untrusted name reaches a file API

A Vapor route reads file from req.query, or an app receives a file name in a server response, and appends it to a directory URL.

Step 2

The name leaves the intended folder

It contains ../ segments, names a sibling folder with the same prefix, or reaches a symbolic link. The benign test value ../../etc/hostname illustrates the read case.

Step 3

Foundation opens the resolved path

streamFile, String(contentsOf:) or Data.write(to:) passes the path to the kernel, which resolves every .. and link.

Step 4

Files leak or are overwritten

A server leaks configuration and keys; an app can have its database, cached credentials or configuration in Application Support overwritten by a malicious download.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// Files.swift: each function lets an untrusted name leave its folder
import Foundation
import Vapor

let uploadDir = URL(fileURLWithPath: "/srv/uploads", isDirectory: true)

// appendingPathComponent keeps ../ and the kernel follows it
func download(_ req: Request) throws -> Response {
    let name = try req.query.get(String.self, at: "file")
    return req.fileio.streamFile(at: uploadDir.appendingPathComponent(name).path)
}

// hasPrefix without / accepts /srv/uploads-old and ignores symlinks
func preview(_ req: Request) throws -> String {
    let name = try req.query.get(String.self, at: "file")
    let path = uploadDir.appendingPathComponent(name).standardizedFileURL.path
    guard path.hasPrefix(uploadDir.path) else { throw Abort(.notFound) }
    return try String(contentsOfFile: path, encoding: .utf8)
}

// a server name such as ../../Library/Preferences/app.plist overwrites app data
func saveAttachment(serverName: String, data: Data, in dir: URL) throws -> URL {
    let target = dir.appendingPathComponent(serverName)
    try data.write(to: target)
    return target
}
HARDENED SECURE PATCH
// Files.swift: standardize, resolve links, compare with a trailing slash
import Foundation
import Vapor

let uploadDir = URL(fileURLWithPath: "/srv/uploads", isDirectory: true).resolvingSymlinksInPath()

// base and target are resolved the same way; the slash stops sibling folders
func inside(_ base: URL, _ name: String) -> URL? {
    let target = base.appendingPathComponent(name).standardizedFileURL.resolvingSymlinksInPath()
    guard target.path.hasPrefix(base.path + "/") else { return nil }
    return target
}

// downloads go through the containment check
func download(_ req: Request) throws -> Response {
    let name = try req.query.get(String.self, at: "file")
    guard let url = inside(uploadDir, name) else { throw Abort(.notFound) }
    return req.fileio.streamFile(at: url.path)
}

// previews use the same check
func preview(_ req: Request) throws -> String {
    let name = try req.query.get(String.self, at: "file")
    guard let url = inside(uploadDir, name) else { throw Abort(.notFound) }
    return try String(contentsOf: url, encoding: .utf8)
}

// only the last component is used and existing files are never replaced
func saveAttachment(serverName: String, data: Data, in dir: URL) throws -> URL {
    let base = dir.resolvingSymlinksInPath()
    guard let target = inside(base, URL(fileURLWithPath: serverName).lastPathComponent) else {
        throw CocoaError(.fileWriteInvalidFileName)
    }
    try data.write(to: target, options: .withoutOverwriting)
    return target
}

Engineering & System Hardening Checklist

Sources