Route RouteExplorer through WKWebView to bypass URLSession block

User reported the Search tab is 403'ing on the deployed app — visible
on their phone, but route-explorer.com itself loads in their phone's
Safari. So the gating differentiates between Safari/WKWebView and
URLSession: same network, same UA strings, different verdict. Most
likely TLS-fingerprint-based.

Solution: route both /api/token and /api/flight-search through the
existing WebViewFetcher service (originally built for the same
purpose against an Akamai-protected airline API). XHRs run from
inside a WKWebView navigated to route-explorer.com, so the request
hits the edge with Safari's TLS fingerprint and any first-party
cookies the gate expects.

Touched:
- RouteExplorerClient.currentToken — now goes via WKWebView XHR
- callFlightSearch — same, with one retry on token rotation
- searchSchedule — same path
- New fetchViaWebView helper takes (method, apiPath, headers, body)
  and returns the response body string

Trade-offs:
- Each call now starts with a WKWebView navigation (~2s). Token is
  cached for 30 min so this hits once per session for most usage.
- Searches are slower than before. Can pool the WKWebView later if
  needed; for now correctness > speed.
- Per-call WKWebView allocation runs on MainActor (forced by
  WebViewFetcher's @MainActor isolation). Awaiting from the actor
  is fine — the bridge is automatic.

If route-explorer relaxes the gate later we can switch back to
URLSession by reverting this commit; the URLSession code path was
preserved up to deletion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-31 13:27:36 -05:00
parent 5c1d7871c6
commit 9612ef558f
+95 -77
View File
@@ -112,23 +112,17 @@ actor RouteExplorerClient {
]
do {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let body = try JSONSerialization.data(withJSONObject: [
"endpoint": "/schedule",
"body": ["json": payload]
])
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = body
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200...299).contains(status) else { return [] }
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token],
requestBody: body
)
guard let data = respStr.data(using: .utf8) else { return [] }
let decoded = try JSONDecoder.routeExplorer().decode(
RouteExplorerScheduleResponse.self, from: data
)
@@ -163,21 +157,22 @@ actor RouteExplorerClient {
if let cached = cachedToken, cached.expiresAt > Date() {
return cached.value
}
let url = baseURL.appendingPathComponent("api/token")
var req = URLRequest(url: url)
req.httpMethod = "GET"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status != 200 {
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
throw ClientError.tokenFetchFailed(status: status)
}
// route-explorer's edge now rejects URLSession-shaped requests
// (returns 403 "clearance"). A WKWebView running inside the
// route-explorer.com origin passes the gate, presumably because
// the TLS fingerprint + same-origin cookies match what their
// bot rules expect. We route both /api/token and
// /api/flight-search through that path.
let bodyStr = try await fetchViaWebView(
method: "GET",
apiPath: "/api/token",
extraHeaders: [:],
requestBody: nil
)
struct TokenResponse: Decodable { let token: String }
guard let data = bodyStr.data(using: .utf8) else {
throw ClientError.tokenFetchFailed(status: -1)
}
do {
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
@@ -187,6 +182,53 @@ actor RouteExplorerClient {
}
}
/// Runs an XHR from inside a WKWebView that's been navigated to
/// `https://route-explorer.com/`. The page context provides the
/// real Safari TLS fingerprint and any first-party cookies the
/// edge expects. Returns the response body as a string, or
/// throws on non-200.
private func fetchViaWebView(
method: String,
apiPath: String,
extraHeaders: [String: String],
requestBody: Data?
) async throws -> String {
let fetcher = await WebViewFetcher()
var headers: [String: String] = [
"Accept": "application/json",
"Content-Type": "application/json"
]
for (k, v) in extraHeaders { headers[k] = v }
// For POST, body must be a JS literal re-emit the JSON as
// a JSON.stringify(...) so it survives template substitution.
var bodyJS: String?
if let requestBody {
// Inline the JSON body verbatim XMLHttpRequest.send()
// takes a string and we're already JSON-encoded.
let raw = String(data: requestBody, encoding: .utf8) ?? "null"
bodyJS = "JSON.stringify(\(raw))"
}
let result = await fetcher.fetch(
navigateTo: "https://route-explorer.com/",
fetchURL: "https://route-explorer.com\(apiPath)",
method: method,
headers: headers,
body: bodyJS,
userAgent: nil,
includeCredentials: true
)
if let err = result.error {
print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
throw ClientError.tokenFetchFailed(status: -1)
}
guard let data = result.data else {
throw ClientError.tokenFetchFailed(status: -1)
}
return data
}
/// Browser-shaped headers `/api/token` and `/api/flight-search` are
/// gated by Origin/Referer in production. Without these the server
/// returns 403 even though the endpoints are otherwise anonymous.
@@ -209,66 +251,42 @@ actor RouteExplorerClient {
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 401 / 403 likely means the token rotated. Drop cache and retry once.
if status == 401 || status == 403 {
do {
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token],
requestBody: bodyData
)
guard let data = respStr.data(using: .utf8) else {
throw ClientError.requestFailed(status: -1, body: nil)
}
return try decode(data: data)
} catch let err as ClientError {
// Token may have rotated server-side. Drop cache and retry once.
if case .tokenFetchFailed = err {
cachedToken = nil
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
}
guard (200...299).contains(status) else {
let bodyStr = String(data: data, encoding: .utf8)
throw ClientError.requestFailed(status: status, body: bodyStr)
}
return try decode(data: data)
}
private func retryAfterTokenRotation(
endpoint: String,
json: [String: Any]
) async throws -> RouteSearchResult {
let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [
"endpoint": endpoint,
"body": ["json": json]
]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url)
req.httpMethod = "POST"
Self.applyBrowserHeaders(to: &req)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(token, forHTTPHeaderField: "X-API-Token")
req.httpBody = bodyData
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200...299).contains(status) else {
throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
let token2 = try await currentToken()
let respStr = try await fetchViaWebView(
method: "POST",
apiPath: "/api/flight-search",
extraHeaders: ["X-API-Token": token2],
requestBody: bodyData
)
guard let data = respStr.data(using: .utf8) else {
throw ClientError.requestFailed(status: -1, body: nil)
}
return try decode(data: data)
}
throw err
}
}
private func decode(data: Data) throws -> RouteSearchResult {
do {