Why are deep links an attack surface, and how do you validate them?

14.4.1 · L3 · Interview

Predict first

A strict validator and a few hostile URLs. Which pass, and what host does the userinfo trick actually name?

import Foundation

func isSafe(_ url: URL) -> Bool {
    guard url.scheme == "myapp" || (url.scheme == "https" && url.host == "app.example.com") else { return false }
    let path = URLComponents(url: url, resolvingAgainstBaseURL: false)?.path ?? ""
    guard !path.contains("..") else { return false }
    return ["/open", "/profile"].contains(path)
}
print(isSafe(URL(string: "myapp:///open")!), isSafe(URL(string: "https://app.example.com/profile")!), isSafe(URL(string: "https://evil.com/profile")!), isSafe(URL(string: "myapp:///../admin")!), isSafe(URL(string: "https://app.example.com.evil.com/open")!))
print(URL(string: "https://app.example.com@evil.com/open")!.host ?? "nil")
  • true true false false false, then evil.com
  • true true true false true, then app.example.com
  • true true false true false, then evil.com

Mental model. A deep link is a stranger handing your receptionist a note that says “do this”. The receptionist should check who the note came from, what it asks, and then ask the boss, never just follow it.

A deep link is a URL that opens your app and tells it what to do (myapp://transfer?to=…&amount=… or a universal link). Any app, web page, QR code or message can trigger it, so treat the URL as untrusted input from an anonymous party, exactly like an HTTP request. The attacks: action injection (a link that performs a sensitive action without confirmation: sending money, deleting data, changing settings, signing in as the attacker via a crafted login callback); parameter tampering and path traversal (../admin); open redirects (a link parameter that sends the user to an attacker’s site inside your trusted web view); URL-scheme hijacking (any app can register the same custom scheme; there is no ownership check, so a malicious app can intercept the OAuth callback and steal the code); and host confusion (checking that a URL “contains” your domain). Custom schemes have no ownership, while universal links are verified by the system against your apple-app-site-association file, so they are the right choice for anything sensitive (see the platform topic for the mechanics).

Defences. Parse with URLComponents, then check the parts against an allow-list: the scheme, the exact host (compare equal, not hasSuffix or contains: app.example.com.evil.com and the userinfo trick https://app.example.com@evil.com/, whose host is evil.com, both defeat sloppy checks), the path from a fixed set of routes, and parameter types and ranges. Never perform a sensitive action directly from a link: show a confirmation screen with the details and require the user to authenticate, and treat the link as navigation, not authorisation. Use state and PKCE for OAuth callbacks, so an intercepted code is useless, and prefer ASWebAuthenticationSession with a universal-link redirect. Do not put secrets in links (they land in logs and history). Handle malformed input without crashing (no force-unwraps of URL parts), check that the user is signed in before routing, and cap what links can reach, then fuzz your router with hostile inputs in tests.

Only the two legitimate links pass. In the userinfo trick everything before the @ is credentials, so the real host is evil.com.

Common traps

  • Validating a host with contains or hasSuffix. app.example.com.evil.com and user-info tricks pass; compare the exact host.
  • Performing an action straight from a custom-scheme link. Any app can send it; require confirmation and authentication, and use universal links for sensitive flows.

They might follow up with

  • Why can an OAuth code leak through a custom scheme? Another app can register the same scheme and receive the callback; use universal links plus PKCE.
  • How do you test a router? Feed it hostile URLs: wrong host, traversal, userinfo, huge parameters, missing parts.

Related questions

  • 14.4.2 Why are secrets in the app binary not secret, and what do you do instead?
  • 14.5.1 How would you threat-model a consumer app that handles accounts, payments and personal data?
  • 6.7.2 What is the difference between a custom URL scheme and a universal link, and which do you use?

Why are secrets in the app binary not secret, and what do you do… →