From 00c3a333ae572ec2d0d95299bdd9fc61f0b6536c Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 17 Sep 2026 17:51:47 -0700 Subject: [PATCH 1/2] chore: [SDK-5282] keep the JWT out of the log pipeline `updateUserJwt` logged the full token at verbose behind a TODO to remove it before shipping, and `OneSignalClient` printed a request's `additionalHeaders` on send and again in the response handler, which is where `OSRequestAuth` puts the Authorization header, so every signed request logged its bearer. `onesignal_Log` hands every entry to app listeners and the internal sink whatever the console level is, so a host listener or remote logging could see the token without anyone turning verbose on. `updateUserJwt` now logs the external ID and the token's last 8 characters, the breadcrumb Android keeps: it cannot be replayed, and it ties a supplied token to a later rejection. The client masks the Authorization value as `` at both log sites and keeps the key, so a log still shows whether a request went out signed. Three listener tests pin it: the exact `updateUserJwt` entry, and the client's request and response lines for a signed request, each asserting no entry contains the token. The client's two private log sites are reachable from the Core tests through a category, so no network round trip is needed. All three fail against the previous source. --- .../Source/API/OneSignalClient.m | 18 ++++++- .../OneSignalCoreTests/LoggingTests.swift | 54 +++++++++++++++++++ .../OneSignalCoreTests-Bridging-Header.h | 7 +++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 6 ++- .../OneSignalUserTests/UserJwtApiTests.swift | 23 ++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m index 1733ecc3f6..5cfee3305d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/API/OneSignalClient.m @@ -175,6 +175,20 @@ - (double)calculateReattemptDelay:(int)reattemptCount { return REATTEMPT_DELAY * pow(3, reattemptCount); } +- (NSDictionary *)loggableHeadersOfRequest:(OneSignalRequest *)request { + NSDictionary *headers = request.additionalHeaders; + if (!headers) { + return nil; + } + NSMutableDictionary *loggable = [headers mutableCopy]; + for (NSString *name in headers) { + if ([name caseInsensitiveCompare:@"Authorization"] == NSOrderedSame) { + loggable[name] = @""; + } + } + return loggable; +} + - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { if (![NSJSONSerialization isValidJSONObject:request.parameters]) return; @@ -190,7 +204,7 @@ - (void)prettyPrintDebugStatementWithRequest:(OneSignalRequest *)request { NSString *jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with parameters: %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, jsonString, request.additionalHeaders]]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"HTTP Request (%@) with URL: %@, with parameters: %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, jsonString, [self loggableHeadersOfRequest:request]]]; } - (void)handleJSONNSURLResponse:(NSURLResponse*)response data:(NSData*)data error:(NSError*)error isAsync:(BOOL)async withRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSClientFailureBlock)failureBlock { @@ -206,7 +220,7 @@ - (void)handleJSONNSURLResponse:(NSURLResponse*)response data:(NSData*)data erro innerJson[@"httpStatusCode"] = [NSNumber numberWithLong:statusCode]; innerJson[@"headers"] = headers; - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network request (%@) with URL %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, request.additionalHeaders]]; + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network request (%@) with URL %@ and headers: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, [self loggableHeadersOfRequest:request]]]; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"network response (%@) with URL %@: %@", NSStringFromClass([request class]), request.urlRequest.URL.absoluteString, innerJson]]; if (jsonError) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift index b0a2cf8fe3..37e479699c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift @@ -169,4 +169,58 @@ final class LoggingTests: XCTestCase { XCTAssertEqual(sink.levels, [.LL_WARN]) XCTAssertEqual(sink.messages, ["raw message"]) } + + // MARK: - Credential redaction + + private let bearer = "jwt-a1b2c3" + + /// Headers as `OSRequestAuth` and the User module set them on a signed request. + private func signedRequest() -> OneSignalRequest { + let request = OneSignalRequest() + request.method = GET + request.path = "apps/test-app-id/users/by/external_id/user-a" + request.parameters = ["app_id": "test-app-id"] + request.additionalHeaders = ["Authorization": "Bearer \(bearer)", "OneSignal-Subscription-Id": "sub-a"] + return request + } + + /// The send log masks the bearer and nothing else. + func testSendingASignedRequestLogsTheHeadersWithTheBearerMasked() throws { + let listener = TestLogListener() + OneSignalLog.debug().__add(listener) + defer { OneSignalLog.debug().__remove(listener) } + let request = signedRequest() + + XCTAssertTrue(OneSignalClient.shared().validRequest(request)) + + let entry = try XCTUnwrap(listener.calls.first { $0.contains("HTTP Request") }) + XCTAssertFalse(entry.contains(bearer), entry) + XCTAssertTrue(entry.contains("Authorization = \"\""), entry) + XCTAssertTrue(entry.contains("sub-a"), entry) + XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + } + + /// The response log masks the bearer too. + func testHandlingAResponseLogsTheHeadersWithTheBearerMasked() throws { + let listener = TestLogListener() + OneSignalLog.debug().__add(listener) + defer { OneSignalLog.debug().__remove(listener) } + let request = signedRequest() + let url = try XCTUnwrap(request.urlRequest().url) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) + let body = try JSONSerialization.data(withJSONObject: ["onesignal_id": "osid-a"]) + var result: [AnyHashable: Any]? + + OneSignalClient.shared().handleJSONNSURLResponse( + response, data: body, error: nil, isAsync: false, with: request, + onSuccess: { result = $0 }, + onFailure: { XCTFail("\($0)") } + ) + + XCTAssertEqual(result?["onesignal_id"] as? String, "osid-a") + let entry = try XCTUnwrap(listener.calls.first { $0.contains("network request") }) + XCTAssertFalse(entry.contains(bearer), entry) + XCTAssertTrue(entry.contains("Authorization = \"\""), entry) + XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h index 1b2cb5d6d0..f831466260 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/OneSignalCoreTests-Bridging-Header.h @@ -2,3 +2,10 @@ // Use this file to import your target's public headers that you would like to expose to Swift. // +#import + +/// The client's log sites, exposed so tests can read what they print without a network round trip. +@interface OneSignalClient (LoggingTests) +- (BOOL)validRequest:(OneSignalRequest *)request; +- (void)handleJSONNSURLResponse:(NSURLResponse *)response data:(NSData *)data error:(NSError *)error isAsync:(BOOL)async withRequest:(OneSignalRequest *)request onSuccess:(OSResultSuccessBlock)successBlock onFailure:(OSClientFailureBlock)failureBlock; +@end diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 5a424932f9..b99b855d53 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -103,8 +103,10 @@ extension OneSignalUserManagerImpl { OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.updateUserJwt called with empty externalId or an unusable token. This is not allowed.") return } - // TODO: omit the token from this log before shipping — keep for testing. - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") + OneSignalLog.onesignalLog( + .LL_VERBOSE, + message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: ...\(token.suffix(8))" + ) storeJwt(externalId: externalId, token: token) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift index f24b446671..dae84565ab 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -32,6 +32,14 @@ import OneSignalUserMocks @testable import OneSignalOSCore @testable import OneSignalUser +private final class CapturingLogListener: NSObject, OSLogListener { + var entries: [String] = [] + + func onLogEvent(_ event: OneSignalLogEvent) { + entries.append(event.entry) + } +} + /** Public JWT surface on the User Manager: store a token, and tell the app when it stopped being accepted. Goes through `sharedInstance` because the JWT config and IV service are shared. @@ -113,6 +121,21 @@ final class UserJwtApiTests: XCTestCase { XCTAssertNil(user.identityModel.jwtBearerToken) } + /// The entry names the user and the token's last 8 characters, never more. + func testUpdateUserJwtLogsTheCallWithOnlyTheTokensTail() { + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + let token = "eyJhbGciOi.eyJzdWIiOi.signature-a1b2c3d4" + let log = CapturingLogListener() + OneSignalLog.debug().__add(log) + defer { OneSignalLog.debug().__remove(log) } + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: token) + + let entry = log.entries.first { $0.contains("updateUserJwt") } + XCTAssertEqual(entry, "VERBOSE: OneSignal.updateUserJwt called for externalId: user-a with token: ...a1b2c3d4") + XCTAssertFalse(log.entries.contains { $0.contains("signature-a1b2c3d4") }, "\(log.entries)") + } + // MARK: - Invalidated listener func testInvalidatingAJwtParksTheTokenAndNotifiesTheApp() { From 7928c85597a0cbe828df0c1070693f7ca56cce55 Mon Sep 17 00:00:00 2001 From: Nan Date: Mon, 21 Sep 2026 09:00:36 -0700 Subject: [PATCH 2/2] test: [SDK-5282] pin the wire header and lock the log capture The redaction tests read the log but never checked what reaches the server, so a helper that masked the request's own headers instead of a copy would still have passed them. Each Core test now asserts that the URL request carries the bearer after the log call. The User test's log listener appends from whatever thread logs. It now holds a lock, and the test reads a snapshot. --- .../OneSignalSDK/OneSignalCoreTests/LoggingTests.swift | 2 ++ .../OneSignalUserTests/UserJwtApiTests.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift index 37e479699c..6a63f245aa 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift @@ -198,6 +198,7 @@ final class LoggingTests: XCTestCase { XCTAssertTrue(entry.contains("Authorization = \"\""), entry) XCTAssertTrue(entry.contains("sub-a"), entry) XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + XCTAssertEqual(request.urlRequest().value(forHTTPHeaderField: "Authorization"), "Bearer \(bearer)") } /// The response log masks the bearer too. @@ -222,5 +223,6 @@ final class LoggingTests: XCTestCase { XCTAssertFalse(entry.contains(bearer), entry) XCTAssertTrue(entry.contains("Authorization = \"\""), entry) XCTAssertFalse(listener.calls.contains { $0.contains(bearer) }, "\(listener.calls)") + XCTAssertEqual(request.urlRequest().value(forHTTPHeaderField: "Authorization"), "Bearer \(bearer)") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift index dae84565ab..6755b732d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -33,10 +33,15 @@ import OneSignalUserMocks @testable import OneSignalUser private final class CapturingLogListener: NSObject, OSLogListener { - var entries: [String] = [] + private let lock = NSLock() + private var captured: [String] = [] + + var entries: [String] { + lock.withLock { captured } + } func onLogEvent(_ event: OneSignalLogEvent) { - entries.append(event.entry) + lock.withLock { captured.append(event.entry) } } }