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 { do {
let token = try await currentToken() let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let body = try JSONSerialization.data(withJSONObject: [ let body = try JSONSerialization.data(withJSONObject: [
"endpoint": "/schedule", "endpoint": "/schedule",
"body": ["json": payload] "body": ["json": payload]
]) ])
var req = URLRequest(url: url) let respStr = try await fetchViaWebView(
req.httpMethod = "POST" method: "POST",
Self.applyBrowserHeaders(to: &req) apiPath: "/api/flight-search",
req.setValue("application/json", forHTTPHeaderField: "Content-Type") extraHeaders: ["X-API-Token": token],
req.setValue("application/json", forHTTPHeaderField: "Accept") requestBody: body
req.setValue(token, forHTTPHeaderField: "X-API-Token") )
req.httpBody = body guard let data = respStr.data(using: .utf8) else { return [] }
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200...299).contains(status) else { return [] }
let decoded = try JSONDecoder.routeExplorer().decode( let decoded = try JSONDecoder.routeExplorer().decode(
RouteExplorerScheduleResponse.self, from: data RouteExplorerScheduleResponse.self, from: data
) )
@@ -163,21 +157,22 @@ actor RouteExplorerClient {
if let cached = cachedToken, cached.expiresAt > Date() { if let cached = cachedToken, cached.expiresAt > Date() {
return cached.value return cached.value
} }
let url = baseURL.appendingPathComponent("api/token") // route-explorer's edge now rejects URLSession-shaped requests
var req = URLRequest(url: url) // (returns 403 "clearance"). A WKWebView running inside the
req.httpMethod = "GET" // route-explorer.com origin passes the gate, presumably because
Self.applyBrowserHeaders(to: &req) // the TLS fingerprint + same-origin cookies match what their
req.setValue("application/json", forHTTPHeaderField: "Accept") // bot rules expect. We route both /api/token and
// /api/flight-search through that path.
let (data, response) = try await session.data(for: req) let bodyStr = try await fetchViaWebView(
let status = (response as? HTTPURLResponse)?.statusCode ?? -1 method: "GET",
if status != 200 { apiPath: "/api/token",
let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>" extraHeaders: [:],
print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))") requestBody: nil
throw ClientError.tokenFetchFailed(status: status) )
}
struct TokenResponse: Decodable { let token: String } struct TokenResponse: Decodable { let token: String }
guard let data = bodyStr.data(using: .utf8) else {
throw ClientError.tokenFetchFailed(status: -1)
}
do { do {
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data) let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60)) 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 /// Browser-shaped headers `/api/token` and `/api/flight-search` are
/// gated by Origin/Referer in production. Without these the server /// gated by Origin/Referer in production. Without these the server
/// returns 403 even though the endpoints are otherwise anonymous. /// returns 403 even though the endpoints are otherwise anonymous.
@@ -209,66 +251,42 @@ actor RouteExplorerClient {
json: [String: Any] json: [String: Any]
) async throws -> RouteSearchResult { ) async throws -> RouteSearchResult {
let token = try await currentToken() let token = try await currentToken()
let url = baseURL.appendingPathComponent("api/flight-search")
let outerBody: [String: Any] = [ let outerBody: [String: Any] = [
"endpoint": endpoint, "endpoint": endpoint,
"body": ["json": json] "body": ["json": json]
] ]
let bodyData = try JSONSerialization.data(withJSONObject: outerBody) let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
var req = URLRequest(url: url) do {
req.httpMethod = "POST" let respStr = try await fetchViaWebView(
Self.applyBrowserHeaders(to: &req) method: "POST",
req.setValue("application/json", forHTTPHeaderField: "Content-Type") apiPath: "/api/flight-search",
req.setValue("application/json", forHTTPHeaderField: "Accept") extraHeaders: ["X-API-Token": token],
req.setValue(token, forHTTPHeaderField: "X-API-Token") requestBody: bodyData
req.httpBody = bodyData )
guard let data = respStr.data(using: .utf8) else {
let (data, response) = try await session.data(for: req) throw ClientError.requestFailed(status: -1, body: nil)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1 }
return try decode(data: data)
// 401 / 403 likely means the token rotated. Drop cache and retry once. } catch let err as ClientError {
if status == 401 || status == 403 { // Token may have rotated server-side. Drop cache and retry once.
if case .tokenFetchFailed = err {
cachedToken = nil cachedToken = nil
return try await retryAfterTokenRotation(endpoint: endpoint, json: json) let token2 = try await currentToken()
} let respStr = try await fetchViaWebView(
method: "POST",
guard (200...299).contains(status) else { apiPath: "/api/flight-search",
let bodyStr = String(data: data, encoding: .utf8) extraHeaders: ["X-API-Token": token2],
throw ClientError.requestFailed(status: status, body: bodyStr) requestBody: bodyData
} )
guard let data = respStr.data(using: .utf8) else {
return try decode(data: data) throw ClientError.requestFailed(status: -1, body: nil)
}
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))
} }
return try decode(data: data) return try decode(data: data)
} }
throw err
}
}
private func decode(data: Data) throws -> RouteSearchResult { private func decode(data: Data) throws -> RouteSearchResult {
do { do {