fix(security): validate extension callback state

This commit is contained in:
zarzet
2026-08-29 18:53:33 +07:00
parent 29db940d63
commit 63d29d2ed4
10 changed files with 236 additions and 51 deletions
+16 -9
View File
@@ -104,25 +104,32 @@ import Gobackend
}
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
/// - OAuth: spotiflac://callback?code=...&state=<one_time_nonce>
/// - Signed session: spotiflac://session-grant?grant=...&state=<one_time_nonce>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let route = ExtensionCallbackParser.parse(url) else { return false }
streamQueue.async {
var err: NSError?
var response: String?
guard let extensionId = GobackendConsumeExtensionCallbackState(
route.state,
&err
), err == nil else {
NSLog("SpotiFLAC Mobile: Rejected invalid or expired extension callback")
return
}
if route.isSessionGrant {
GobackendSetExtensionSessionGrantByID(route.extensionId, route.code)
GobackendSetExtensionSessionGrantByID(extensionId, route.code)
response = GobackendInvokeExtensionActionJSON(
route.extensionId,
extensionId,
"completeGrant",
&err
)
} else {
GobackendSetExtensionAuthCodeByID(route.extensionId, route.code)
GobackendSetExtensionAuthCodeByID(extensionId, route.code)
response = GobackendInvokeExtensionActionJSON(
route.extensionId,
extensionId,
"completeSpotifyLogin",
&err
)
@@ -130,7 +137,7 @@ import Gobackend
if err == nil && route.isSessionGrant {
do {
try self.requireSuccessfulExtensionAction(
extensionId: route.extensionId,
extensionId: extensionId,
actionName: "completeGrant",
response: response
)
@@ -140,11 +147,11 @@ import Gobackend
}
if let err = err {
NSLog(
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
"SpotiFLAC Mobile: Extension callback failed (code \(err.code))")
} else if route.isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(
extensionId: route.extensionId
extensionId: extensionId
)
}
}
+4 -4
View File
@@ -2,7 +2,7 @@ import Foundation
struct ExtensionCallbackRoute: Equatable {
let code: String
let extensionId: String
let state: String
let isSessionGrant: Bool
}
@@ -36,17 +36,17 @@ enum ExtensionCallbackParser {
?? queryItems.first { $0.name == "code" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? ""
let extensionId =
let state =
queryItems.first { $0.name == "state" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? ""
guard !code.isEmpty, !extensionId.isEmpty else {
guard !code.isEmpty, !state.isEmpty else {
return nil
}
return ExtensionCallbackRoute(
code: code,
extensionId: extensionId,
state: state,
isSessionGrant: isSessionGrant
)
}
+2 -2
View File
@@ -12,7 +12,7 @@ class RunnerTests: XCTestCase {
route,
ExtensionCallbackRoute(
code: "auth-code",
extensionId: "spotify-web",
state: "spotify-web",
isSessionGrant: false
)
)
@@ -30,7 +30,7 @@ class RunnerTests: XCTestCase {
route,
ExtensionCallbackRoute(
code: "session-token",
extensionId: "provider",
state: "provider",
isSessionGrant: true
)
)