C.W.K.
Stream
Lesson 04 of 05 · published

Both Sides Validate

~15 min · identity-handoff, defense-in-depth, path-traversal, validation

Level 0Reel Novice
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Both sides validate. Recall resolves an allowlisted DB path; Ashen Reel independently checks the returned canonical file and root before opening."

Two Checks, Not One

When the deep link arrives, Ashen Reel doesn't open anything yet. It queries its configured loopback Recall service — a fixed local base URL that the incoming link cannot override — and asks Recall to turn the video_id into a real path. Recall does its job: it looks the id up in its database and returns a path it has already allowlisted. That could be the end of the story. It isn't.

Ashen Reel then validates the path again, on its own side of the boundary. It canonicalizes the returned file, confirms it's a real regular file, and checks that — after every symlink is resolved — it still lives inside a configured Recall media root. Only then does it open. Two independent parties each check, because either one alone is a single point of failure.

Trust is not verification. Validate on your own side of every boundary you cross. 'The other service already checked' is how a single bug in one component becomes a breach in the whole system. Defense in depth means each layer assumes the others might be wrong.

The Symlink Escape

Here's the subtle attack that 'is this path inside my folder?' misses if you do it naively. Suppose an allowed root contains a symlink pointing out to somewhere sensitive. A string-prefix check on the literal path says "yes, it starts with the allowed root" and waves it through — even though following the link lands outside. The fix is order-of-operations: resolve symlinks first, then check containment. You verify where the path actually leads, not where it superficially claims to be.

Canonicalize before you compare. Path checks that operate on the raw string are almost always bypassable — with .. segments, symlinks, or encoding tricks. Resolve the path to its real, absolute, symlink-free form first; compare that. A comparison is only as trustworthy as the canonicalization in front of it.

The independent re-check is short, and the order of the two steps is the whole security:

Why the App Owns the Base URL

One more quiet defense: the loopback Recall address Ashen Reel talks to is its own configured value, never a field in the incoming link. If the URL could say "resolve this id against http://evil.example/api," the attacker would supply the answers to the very lookup meant to constrain them. So the base URL isn't negotiable — the link names a video, and the app alone decides which trusted service resolves it. Every place the attacker might have inserted themselves is a place the design took off the table.

Code

The independent re-check — resolve symlinks FIRST, then check containment·swift
/// Recall already resolved video_id -> path from its allowlisted DB.
/// Ashen Reel STILL checks, because trust is not verification.
func validateResolved(_ path: String, roots: [URL]) -> Bool {
    // Canonicalize: resolve symlinks and '..' to the REAL absolute path.
    let url = URL(fileURLWithPath: path)
        .resolvingSymlinksInPath()
        .standardizedFileURL

    // 1. Must be a real, regular file -- not a directory, socket, or device.
    let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]))?
        .isRegularFile == true
    guard isFile else { return false }

    // 2. After symlinks are resolved, it must STILL be inside an allowed root.
    let real = url.path
    return roots.contains { root in
        let base = root.resolvingSymlinksInPath().standardizedFileURL.path
        return real == base || real.hasPrefix(base + "/")
    }
}
// The order matters: a prefix check BEFORE resolving symlinks is bypassable;
// a prefix check AFTER resolving them checks where the path really leads.

External links

Exercise

Find a boundary in your system where component A resolves or validates something and hands the result to component B. Does B re-check, or does it trust A's result? Now find any place you check whether a path is 'inside' an allowed directory. Are you resolving symlinks and '..' before the check, or comparing raw strings? Fix one raw-string containment check to canonicalize first.
Hint
Two independent tells: (1) 'the other side already validated it' anywhere in your reasoning is a defense-in-depth gap — re-check on your own side. (2) Any containment check (hasPrefix, startsWith, string compare on paths) that runs before symlink/.. resolution is bypassable; canonicalize, then compare.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.