diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3eeb22..d376628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Unit tests (iOS Simulator) runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Show toolchain run: xcodebuild -version - name: Verify PJSIP binary @@ -27,7 +27,7 @@ jobs: name: Build for device runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build arm64 device slice run: ACTION=build DESTINATION='generic/platform=iOS' ./Scripts/run-package-tests.sh @@ -35,7 +35,7 @@ jobs: name: Strict Swift concurrency runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build with complete checking run: >- ACTION=build DESTINATION='generic/platform=iOS Simulator' @@ -46,6 +46,6 @@ jobs: name: Lint podspec runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: pod lib lint run: pod lib lint CallWaveKit.podspec --skip-tests --platforms=ios diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3bf6c..2eefbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,46 @@ bump may contain breaking changes, and each one is listed below. ## [Unreleased] +### Security + +- The bundled PJSIP base remains 2.17 for compatibility, but its build now + backports the upstream fixes for the Service-Route and SDP `a=crypto` stack + overflows, malformed RED negotiation, forged legacy-STUN responses, the SIP + header off-by-one, and PJLIB-UTIL HTTP/CLI overflows. The remote payload-type + map bounds fix is narrowly adapted to the 2.17 source instead of importing + unrelated unreleased 2.18 work; every upstream commit is pinned in the build + script and recorded in `Vendor/PJSIP-BUILD.txt`. +- The built-in VoIP push parser now type-checks UUID and caller fields. An + `NSNull`, number or collection supplied by a malformed remote payload used to + receive NSString selectors and terminate the process; invalid values now use + a generated UUID and the documented `Unknown` caller fallback. +- Final-response diagnostics now pass both the peer address and Call-ID through + identifier redaction. They previously exposed raw SIP identifiers even while + `CallWaveLog.redactsIdentifiers` was enabled. + ### Fixed +- PJSUA runtime ownership is claimed for the whole initialization window, not + only after `isRunning` becomes true, so two clients cannot initialize the + process-global stack concurrently on different queues. Start/stop state is + now published in the same serialized queue turn as the native transition, + and a failed start destroys every partially initialized PJSIP global before + releasing ownership. +- VoIP-push registration wake-up and `stop()` now perform their PJSIP checks, + registration and complete destruction as serialized SIP-queue operations. + The previous check ran on a generic queue, allowing `stop()` to destroy the + runtime immediately before the push called into it. +- Final-response tracking stores the complete Call-ID rather than silently + truncating it to `PJSIP_MAX_URL_SIZE`; ACKs for longer valid identifiers now + release the teardown drain. Expired-entry logging also runs after releasing + the tracking lock, so a reentrant host logger cannot deadlock the client. + Expired slots are reclaimed during normal call traffic and all remaining + transaction records are freed when the PJSUA runtime is destroyed. +- Retransmissions of the same non-2xx final INVITE response now reuse one + teardown tracking entry. Previously every retransmitted `603` occupied a new + slot while its ACK cleared only one, leaving phantom unacknowledged responses + that delayed later `logout()` and `stop()` calls and eventually exhausted the + fixed tracking table. - The 0.6.0 teardown drain did not cover a declined call — the case it was written for. It waited on `pjsua_call_get_count()`, whose documentation says it includes "calls that are no longer active but still in the process of @@ -26,6 +64,9 @@ bump may contain breaking changes, and each one is listed below. ### Changed +- SwiftPM and CocoaPods now consume the same vendored PJSIP XCFramework. The + old SwiftPM URL still pointed at the immutable, unpatched 2.17 release asset, + which would have bypassed the security rebuild used by CocoaPods. - The decline path is observed by a `pjsip_module` registered on the endpoint ahead of the transaction layer, rather than by PJSUA's per-call `on_call_tsx_state`. That callback never fired for a declined call: PJSUA diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index f8eacab..2fe807b 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -22,6 +22,8 @@ #import #import #import +#import +#import #import #if __has_include() #import @@ -67,7 +69,8 @@ /// `on_call_tsx_state` never fires for one either. typedef struct { pj_bool_t inUse; - char callId[PJSIP_MAX_URL_SIZE]; + char *callId; + pj_size_t callIdLength; int cseq; int statusCode; NSTimeInterval sentAt; @@ -90,6 +93,7 @@ static void onCallMediaState(pjsua_call_id callId); static void onRegistrationState(pjsua_acc_id accId); static void onPJLog(int level, const char *data, int length); +static NSString *stringFromPJString(pj_str_t value); static pthread_key_t gPJThreadKey; static pthread_once_t gPJThreadKeyOnce = PTHREAD_ONCE_INIT; @@ -149,8 +153,19 @@ static BOOL ensurePJThreadRegistered(const char *name) { /// it. static void forgetFinalResponseLocked(CallWaveFinalResponse *entry) { + if (entry->callId != NULL) { + free(entry->callId); + entry->callId = NULL; + } + entry->callIdLength = 0; entry->inUse = PJ_FALSE; - entry->callId[0] = '\0'; +} + +static BOOL finalResponseMatchesCallID(const CallWaveFinalResponse *entry, + pj_str_t callId) { + return entry->callId != NULL && callId.ptr != NULL && callId.slen > 0 && + entry->callIdLength == (pj_size_t)callId.slen && + memcmp(entry->callId, callId.ptr, entry->callIdLength) == 0; } /// Number of final responses still waiting for an ACK, reclaiming any that have @@ -158,6 +173,8 @@ static void forgetFinalResponseLocked(CallWaveFinalResponse *entry) { static unsigned pendingFinalResponseCount(void) { NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; unsigned pending = 0; + CallWaveFinalResponse expired[CallWaveMaxTrackedFinalResponses] = {0}; + unsigned expiredCount = 0; os_unfair_lock_lock(&gFinalResponseLock); for (int i = 0; i < CallWaveMaxTrackedFinalResponses; i++) { CallWaveFinalResponse *entry = &gFinalResponses[i]; @@ -165,16 +182,33 @@ static unsigned pendingFinalResponseCount(void) { continue; } if (now - entry->sentAt > CallWaveFinalResponseLifetime) { - CWLogError(CallWaveLogCategoryCall, - @"%d for Call-ID %s was never ACKed within %.0fs. The peer never " - @"confirmed the teardown and may still have the call up.", - entry->statusCode, entry->callId, CallWaveFinalResponseLifetime); + expired[expiredCount++] = *entry; + // Transfer ownership to the local record so forgetting the slot + // does not free the identifier before it is logged below. + entry->callId = NULL; forgetFinalResponseLocked(entry); continue; } pending++; } os_unfair_lock_unlock(&gFinalResponseLock); + + // A host logger is arbitrary application code and may call back into the + // client. Never invoke it while holding the non-recursive tracking lock. + for (unsigned i = 0; i < expiredCount; i++) { + CallWaveFinalResponse *entry = &expired[i]; + NSString *callId = entry->callId != NULL + ? [[NSString alloc] initWithBytes:entry->callId + length:entry->callIdLength + encoding:NSUTF8StringEncoding] + : nil; + CWLogError(CallWaveLogCategoryCall, + @"%d for Call-ID %@ was never ACKed within %.0fs. The peer never " + @"confirmed the teardown and may still have the call up.", + entry->statusCode, CWRedact(callId ?: @""), + CallWaveFinalResponseLifetime); + free(entry->callId); + } return pending; } @@ -195,37 +229,68 @@ static pj_status_t onFinalResponseSent(pjsip_tx_data *tdata) { int code = msg->line.status.code; pjsip_cseq_hdr *cseq = PJSIP_MSG_CSEQ_HDR(msg); pjsip_cid_hdr *cid = PJSIP_MSG_CID_HDR(msg); - if (code < 300 || cseq == NULL || cid == NULL || + if (code < 300 || cseq == NULL || cid == NULL || cid->id.ptr == NULL || + cid->id.slen <= 0 || pjsip_method_cmp(&cseq->method, pjsip_get_invite_method()) != 0) { return PJ_SUCCESS; } + // A long-running client may never call the teardown drain between calls. + // Reclaim timed-out entries here too, otherwise 32 peers that never ACK + // can permanently exhaust the fixed tracking table until logout/stop. + (void)pendingFinalResponseCount(); + NSTimeInterval now = NSDate.timeIntervalSinceReferenceDate; os_unfair_lock_lock(&gFinalResponseLock); CallWaveFinalResponse *slot = NULL; + BOOL retransmission = NO; for (int i = 0; i < CallWaveMaxTrackedFinalResponses; i++) { - if (!gFinalResponses[i].inUse) { - slot = &gFinalResponses[i]; + CallWaveFinalResponse *entry = &gFinalResponses[i]; + if (entry->inUse && entry->cseq == cseq->cseq && + finalResponseMatchesCallID(entry, cid->id)) { + slot = entry; + retransmission = YES; break; } - } - if (slot != NULL) { - slot->inUse = PJ_TRUE; - slot->cseq = cseq->cseq; - slot->statusCode = code; - slot->sentAt = now; - pj_ansi_snprintf(slot->callId, sizeof(slot->callId), "%.*s", - (int)cid->id.slen, cid->id.ptr); + if (!entry->inUse && slot == NULL) { + slot = entry; + } + } + if (slot != NULL && !retransmission) { + pj_size_t length = (pj_size_t)cid->id.slen; + char *copy = malloc(length + 1); + if (copy != NULL) { + memcpy(copy, cid->id.ptr, length); + copy[length] = '\0'; + slot->inUse = PJ_TRUE; + slot->callId = copy; + slot->callIdLength = length; + slot->cseq = cseq->cseq; + slot->statusCode = code; + slot->sentAt = now; + } else { + slot = NULL; + } } os_unfair_lock_unlock(&gFinalResponseLock); // The destination is the part a host cannot get anywhere else without a // packet capture: it says the response reached the transport and where it // was addressed. - CWLogInfo(CallWaveLogCategoryCall, - @"%d sent to %s:%d for Call-ID %.*s, waiting for the ACK", - code, tdata->tp_info.dst_name, tdata->tp_info.dst_port, - (int)cid->id.slen, cid->id.ptr); + NSString *callId = stringFromPJString(cid->id); + NSString *destinationName = + [NSString stringWithUTF8String:tdata->tp_info.dst_name] ?: @""; + NSString *destination = [NSString stringWithFormat:@"%@:%d", + destinationName ?: @"", tdata->tp_info.dst_port]; + if (retransmission) { + CWLogInfo(CallWaveLogCategoryCall, + @"%d retransmitted to %@ for Call-ID %@, still waiting for the ACK", + code, CWRedact(destination), CWRedact(callId)); + } else { + CWLogInfo(CallWaveLogCategoryCall, + @"%d sent to %@ for Call-ID %@, waiting for the ACK", + code, CWRedact(destination), CWRedact(callId)); + } if (slot == NULL) { CWLogWarning(CallWaveLogCategoryCall, @"no free slot to track %d; its ACK will not be reported", code); @@ -254,7 +319,7 @@ static pj_bool_t onRequestReceived(pjsip_rx_data *rdata) { if (!entry->inUse || entry->cseq != cseq->cseq) { continue; } - if (pj_strcmp2(&cid->id, entry->callId) != 0) { + if (!finalResponseMatchesCallID(entry, cid->id)) { continue; } acked = entry->statusCode; @@ -406,6 +471,10 @@ static void deleteAccountLocked(void) { encoding:NSUTF8StringEncoding] ?: @""; } +static NSString *stringPayloadValue(id value) { + return [value isKindOfClass:NSString.class] ? value : nil; +} + /// `pjsua_acc_info.expires` is `PJSIP_EXPIRES_NOT_SPECIFIED` (0xFFFFFFFF), not /// zero, once the registration session is gone — which is exactly the state a /// successful un-REGISTER leaves behind, with `status` still 200. The field is @@ -555,6 +624,7 @@ - (void)handleMediaStateForCall:(pjsua_call_id)callId; - (void)handleRegistrationStatus:(int)status active:(BOOL)active reason:(NSString *)reason; ++ (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue; @end @implementation CallWaveClient { @@ -704,14 +774,18 @@ - (void)dealloc { BOOL ownsRuntime = NO; @synchronized (CallWaveClient.class) { ownsRuntime = gActiveClient == self; - if (ownsRuntime) { - gActiveClient = nil; - } } if (ownsRuntime) { // Deliberately not `-stop`: a block that captures `self` during // dealloc resurrects it. Only globals are touched here. [CallWaveClient destroyRuntimeOnQueue:_sipQueue]; + // Ownership must cover destruction. Releasing it first lets a new + // client initialize PJSUA while this dealloc is tearing it down. + @synchronized (CallWaveClient.class) { + if (gActiveClient == self) { + gActiveClient = nil; + } + } } } } @@ -821,7 +895,10 @@ - (BOOL)validateEngineConfigurationWithError:(NSError **)error { - (BOOL)claimRuntimeWithError:(NSError **)error { @synchronized (CallWaveClient.class) { - if (gActiveClient != nil && gActiveClient != self && gActiveClient.isRunning) { + // Ownership begins before PJSUA starts. Looking only at `isRunning` + // leaves the whole initialization window unprotected and lets two + // clients mutate the process-global runtime from different queues. + if (gActiveClient != nil && gActiveClient != self) { if (error != NULL) { *error = CallWaveMakeError(CallWaveErrorEngineAlreadyRunning, @"Another CallWaveClient owns the PJSUA runtime."); @@ -835,7 +912,16 @@ - (BOOL)claimRuntimeWithError:(NSError **)error { - (BOOL)startEngineWithError:(NSError **)error { if (self.isRunning) { - return YES; + BOOL ownsRuntime = NO; + @synchronized (CallWaveClient.class) { + ownsRuntime = gActiveClient == self; + } + if (ownsRuntime) { + return YES; + } + // Recover from an interrupted/stale local projection without ever + // treating another client's process-global runtime as ours. + self.running = NO; } if (![self validateEngineConfigurationWithError:error]) { return NO; @@ -847,21 +933,47 @@ - (BOOL)startEngineWithError:(NSError **)error { CallWaveLog.level = self.engineConfiguration.logLevel; __block pj_status_t status = PJ_SUCCESS; + __block BOOL ownershipLost = NO; [self performSIPSync:^{ - status = [self startEngineLocked]; - }]; - if (status != PJ_SUCCESS) { @synchronized (CallWaveClient.class) { - if (gActiveClient == self) { - gActiveClient = nil; + ownershipLost = gActiveClient != self; + } + if (ownershipLost) { + return; + } + status = [self startEngineLocked]; + if (status == PJ_SUCCESS) { + // Publish success in the same serialized turn as native startup. + // A stop already queued behind this block can then reliably win by + // setting the state back to stopped, without this method writing a + // late `YES` after destruction. + self.running = YES; + } else { + // `startEngineLocked` can fail after pjsua_create()/pjsua_init(). A + // partially initialized process-global runtime is not safe to + // reuse on the next attempt, so clean and release it before any + // queued stop/start transition can run. + [CallWaveClient destroyRuntimeOnQueue:self.sipQueue]; + @synchronized (CallWaveClient.class) { + if (gActiveClient == self) { + gActiveClient = nil; + } } } + }]; + if (ownershipLost) { + if (error != NULL) { + *error = CallWaveMakeError(CallWaveErrorEngineNotRunning, + @"Engine start was superseded by stop()."); + } + return NO; + } + if (status != PJ_SUCCESS) { if (error != NULL) { *error = CallWaveMakeSIPError(status, @"PJSIP start"); } return NO; } - self.running = YES; [self startPathMonitorIfNeeded]; return YES; } @@ -1395,22 +1507,58 @@ + (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue { } - (void)stop { - if (!self.isRunning && gActiveClient != self) { + BOOL shouldStop = self.isRunning; + if (!shouldStop) { + @synchronized (CallWaveClient.class) { + shouldStop = gActiveClient == self; + } + } + if (!shouldStop) { return; } - NSArray *calls = [self.registry removeAllCalls]; + __block NSArray *calls = @[]; + __block BOOL didStop = NO; [self performSIPSync:^{ - ensurePJThreadRegistered("CallWaveStop"); - for (CallWaveCall *call in calls) { - if (call.callId != CallWaveSIPCallIdInvalid && pjsua_call_is_active(call.callId)) { - [self endSIPCall:call.callId - declineStatus:PJSIP_SC_DECLINE - reason:@"the engine is stopping"]; - } + BOOL ownsRuntime = NO; + @synchronized (CallWaveClient.class) { + ownsRuntime = gActiveClient == self; + } + if (!self.isRunning && !ownsRuntime) { + return; } + + didStop = YES; + calls = [self.registry removeAllCalls]; [self stopPathMonitorLocked]; + if (ownsRuntime) { + ensurePJThreadRegistered("CallWaveStop"); + for (CallWaveCall *call in calls) { + if (call.callId != CallWaveSIPCallIdInvalid && + pjsua_call_is_active(call.callId)) { + [self endSIPCall:call.callId + declineStatus:PJSIP_SC_DECLINE + reason:@"the engine is stopping"]; + } + } + // Keep destruction and the published lifecycle transition in this + // single queue turn. A push queued after stop will then observe a + // fully stopped runtime and restart it; one queued before stop + // completes before destruction. + [CallWaveClient destroyRuntimeOnQueue:self.sipQueue]; + } + self.running = NO; + self.registrationState = CallWaveRegistrationStateStopped; + self.registrationError = nil; + @synchronized (CallWaveClient.class) { + if (gActiveClient == self) { + gActiveClient = nil; + } + } }]; + if (!didStop) { + return; + } // Without this the calls stay on the CallKit call list after the stack is // gone, and the user is left looking at a call that cannot be ended. dispatchMain(^{ @@ -1422,16 +1570,6 @@ - (void)stop { [self.callStateMachine resetToIdle]; }); - [CallWaveClient destroyRuntimeOnQueue:self.sipQueue]; - - self.running = NO; - self.registrationState = CallWaveRegistrationStateStopped; - self.registrationError = nil; - @synchronized (CallWaveClient.class) { - if (gActiveClient == self) { - gActiveClient = nil; - } - } } #pragma mark - Network and application lifecycle @@ -1446,7 +1584,7 @@ - (void)startPathMonitorIfNeeded { return; } [self performSIPAsync:^{ - if (self.pathMonitor != nil) { + if (!self.isRunning || self.pathMonitor != nil) { return; } nw_path_monitor_t monitor = nw_path_monitor_create(); @@ -3163,7 +3301,10 @@ - (void)registerForVoIPPushes { /// Re-registers so the intercom's INVITE can reach the device, without /// recreating the stack. - (void)wakeRegistration { - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self performSIPAsync:^{ + // Keep the validity check and the operation it guards in the same + // serialized queue turn as start/stop. Otherwise stop() can destroy + // PJSUA after the check but before registration is refreshed. if (!gPJSUAStarted || gAccountId == PJSUA_INVALID_ID || !pjsua_acc_is_valid(gAccountId)) { NSError *error = nil; if (![self startWithError:&error] && error != nil) { @@ -3171,11 +3312,9 @@ - (void)wakeRegistration { } return; } - [self performSIPSync:^{ - ensurePJThreadRegistered("CallWavePushRegister"); - pjsua_acc_set_registration(gAccountId, PJ_TRUE); - }]; - }); + ensurePJThreadRegistered("CallWavePushRegister"); + pjsua_acc_set_registration(gAccountId, PJ_TRUE); + }]; } - (void)handleVoIPPushPayload:(NSDictionary *)payload { @@ -3192,9 +3331,19 @@ - (CallWaveIncomingCallDescriptor *)descriptorForPushPayload:(NSDictionary *)pay } NSDictionary *data = [payload[@"data"] isKindOfClass:NSDictionary.class] ? payload[@"data"] : nil; - NSString *uuidString = data[@"uuid"] ?: payload[@"uuid"]; - NSUUID *uuid = uuidString.length > 0 ? [[NSUUID alloc] initWithUUIDString:uuidString] : nil; - NSString *caller = data[@"callerID"] ?: data[@"caller"] ?: payload[@"caller_id"]; + NSString *uuidString = stringPayloadValue(data[@"uuid"]); + NSUUID *uuid = uuidString.length > 0 + ? [[NSUUID alloc] initWithUUIDString:uuidString] + : nil; + if (uuid == nil) { + uuidString = stringPayloadValue(payload[@"uuid"]); + uuid = uuidString.length > 0 + ? [[NSUUID alloc] initWithUUIDString:uuidString] + : nil; + } + NSString *caller = stringPayloadValue(data[@"callerID"]); + if (caller.length == 0) caller = stringPayloadValue(data[@"caller"]); + if (caller.length == 0) caller = stringPayloadValue(payload[@"caller_id"]); if ([self payloadAnnouncesCancellation:payload data:data]) { // The caller hung up before anyone answered; `caller` is irrelevant // because nothing is ever shown for a cancellation. @@ -3209,10 +3358,10 @@ - (CallWaveIncomingCallDescriptor *)descriptorForPushPayload:(NSDictionary *)pay /// second. Hosts with a different marker shape set `pushPayloadParser` and /// return a descriptor whose `cancellation` flag is set. - (BOOL)payloadAnnouncesCancellation:(NSDictionary *)payload data:(nullable NSDictionary *)data { - NSString *type = data[@"type"] ?: data[@"event"] ?: payload[@"type"] ?: payload[@"event"]; - if (![type isKindOfClass:NSString.class]) { - return NO; - } + NSString *type = stringPayloadValue(data[@"type"]); + if (type.length == 0) type = stringPayloadValue(data[@"event"]); + if (type.length == 0) type = stringPayloadValue(payload[@"type"]); + if (type.length == 0) type = stringPayloadValue(payload[@"event"]); NSString *normalized = type.lowercaseString; return [normalized isEqualToString:@"cancel"] || [normalized isEqualToString:@"cancelled"] diff --git a/Package.swift b/Package.swift index a626778..582b7fc 100644 --- a/Package.swift +++ b/Package.swift @@ -17,12 +17,13 @@ let package = Package( ) ], targets: [ - // Prebuilt PJSIP 2.17 with Opus and SHA-256 digest support; rebuild - // instructions live in Vendor/PJSIP-BUILD.txt. + // Prebuilt PJSIP 2.17 with pinned security backports, Opus and SHA-256 + // digest support; exact provenance lives in Vendor/PJSIP-BUILD.txt. + // The artifact is already tracked for CocoaPods, so using that same + // copy keeps both package managers on one audited binary. .binaryTarget( name: "PJSIP", - url: "https://github.com/PetrShtuka/CallWaveKit/releases/download/pjsip-2.17-ios15-opus-sha256.2/PJSIP.xcframework.zip", - checksum: "f534773f4dc0e813d0e7a7e3be10a751804232721876d1bad286ab3cba0d16a4" + path: "Vendor/PJSIP.xcframework" ), .target( name: "CallWaveKit", diff --git a/Patches/pjsip-2.17-sdp-map-bounds.patch b/Patches/pjsip-2.17-sdp-map-bounds.patch new file mode 100644 index 0000000..a3d4798 --- /dev/null +++ b/Patches/pjsip-2.17-sdp-map-bounds.patch @@ -0,0 +1,31 @@ +diff --git a/pjmedia/src/pjmedia/sdp_neg.c b/pjmedia/src/pjmedia/sdp_neg.c +index f03c7d1..62560d0 100644 +--- a/pjmedia/src/pjmedia/sdp_neg.c ++++ b/pjmedia/src/pjmedia/sdp_neg.c +@@ -42,3 +42,10 @@ typedef pj_int8_t codec_to_pt_map[PJMEDIA_CODEC_MGR_MAX_CODECS]; + typedef pj_int8_t codec_to_pt_map[PJMEDIA_CODEC_MGR_MAX_CODECS]; +- ++ ++/* Remote SDP payload types must fit the fixed-size dynamic PT maps before ++ * they are used as indices. */ ++PJ_INLINE(pj_bool_t) is_dynamic_pt(unsigned pt) ++{ ++ return pt >= START_DYNAMIC_PT && pt < START_DYNAMIC_PT + DYNAMIC_PT_SIZE; ++} ++ + /** +@@ -1920,3 +1927,3 @@ static pj_status_t assign_pt_and_update_map(pj_pool_t *pool, + pt = pj_strtoul(&rtpmap.pt); +- if (pt < START_DYNAMIC_PT) ++ if (!is_dynamic_pt(pt)) + continue; +@@ -2041,3 +2048,3 @@ static pj_status_t assign_pt_and_update_map(pj_pool_t *pool, + pt = pj_strtoul(&fmtp.fmt); +- if (pt < START_DYNAMIC_PT) ++ if (!is_dynamic_pt(pt)) + continue; +@@ -2058,3 +2065,3 @@ static pj_status_t assign_pt_and_update_map(pj_pool_t *pool, + pt = pj_strtoul(&sdp_m->desc.fmt[j]); +- if (pt < START_DYNAMIC_PT) ++ if (!is_dynamic_pt(pt)) + continue; diff --git a/SECURITY.md b/SECURITY.md index a88280e..a5923ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,20 @@ identifiers in standard logs and omits SIP URIs, Authorization headers and TURN credentials from diagnostics. Debug-level PJSIP traces can contain full SIP messages; do not enable them in App Store or TestFlight builds. +## Bundled PJSIP + +The binary is built from the PJSIP 2.17 release plus a fixed, reviewable set of +upstream security backports. Their full commit IDs and the one narrow 2.17 SDP +adaptation live in `Scripts/build-pjsip-xcframework.sh`; every generated binary +records the same list in `Vendor/PJSIP-BUILD.txt`. Do not replace the artifact +with an unpatched stock 2.17 build. + +CallWaveKit builds with video disabled and Apple's TLS backend, and it does not +run the PJLIB-UTIL HTTP/telnet clients or act as a SIP proxy that re-serializes +received multipart messages. Direct use of the transitive `PJSIP` module is not +a supported API surface. Review the upstream pjproject advisory list before +every release and rebuild the XCFramework when another applicable fix lands. + ## PJSIP licence The public PJSIP binary is GPL-2.0-or-later. Applications distributing it must diff --git a/Scripts/build-pjsip-xcframework.sh b/Scripts/build-pjsip-xcframework.sh index 02125f7..69bc3f2 100755 --- a/Scripts/build-pjsip-xcframework.sh +++ b/Scripts/build-pjsip-xcframework.sh @@ -7,6 +7,19 @@ OPUS_VERSION="${OPUS_VERSION:-1.5.2}" MIN_IOS_VERSION="${MIN_IOS_VERSION:-15.0}" BUILD_JOBS="${BUILD_JOBS:-8}" +# PJSIP 2.17 predates the upstream fixes below and no patched stable release +# exists yet. Keep the released base for compatibility, then apply the exact +# maintainer commits instead of building a moving master branch. +PJSIP_SECURITY_COMMITS=( + acc03b57cef7a7d31b8e1f5b9117437d7e87c591 # Service-Route stack overflow + a1b707c0c9b0506faf2a8a438b60f11ffd6a6fd9 # SDP a=crypto stack overflow + d6a0e7f76611c3a6f530ee051e3e7a622bb1748c # SIP header off-by-one + 8d5956afab2ede95ddb199078dc19a8ac0114f3d # HTTP response heap overflow + 628b71638465bacf66e767959e6acbab822eccd6 # telnet CLI history overflow + 082948b0a2ed658229fc6a50e475b411c69b0d2a # forged simple-STUN response + fd9074547f4740de86548076c36d8d25be51fab3 # malformed RED SDP crash +) + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" OUTPUT_PATH="$PROJECT_ROOT/Vendor/PJSIP.xcframework" @@ -54,6 +67,37 @@ if [[ ! -f "$SOURCE_ROOT/.callwave-patched" ]]; then touch "$SOURCE_ROOT/.callwave-patched" fi +# Each upstream security fix is a merge commit. Fetch its two parents and +# apply only the first-parent diff, without creating local commits or requiring +# git user identity. Per-fix stamps make a pinned work directory resumable. +for security_commit in "${PJSIP_SECURITY_COMMITS[@]}"; do + security_stamp="$SOURCE_ROOT/.callwave-security-$security_commit" + if [[ -f "$security_stamp" ]]; then + continue + fi + git -C "$SOURCE_ROOT" fetch --quiet --depth 2 origin "$security_commit" + if git -C "$SOURCE_ROOT" diff "$security_commit^1" "$security_commit" -- \ + | git -C "$SOURCE_ROOT" apply --check -; then + git -C "$SOURCE_ROOT" diff "$security_commit^1" "$security_commit" -- \ + | git -C "$SOURCE_ROOT" apply - + elif ! git -C "$SOURCE_ROOT" diff "$security_commit^1" "$security_commit" -- \ + | git -C "$SOURCE_ROOT" apply --reverse --check -; then + echo "security backport $security_commit does not apply to PJSIP $PJSIP_VERSION" >&2 + exit 1 + fi + touch "$security_stamp" +done + +# GHSA-rfwg-w9gq-9mw2's master-branch patch includes unrelated 2.18 RED +# changes, so this narrow 2.17 adaptation applies the same missing upper-bound +# checks without importing unreleased feature work. +SDP_BOUNDS_PATCH="$PROJECT_ROOT/Patches/pjsip-2.17-sdp-map-bounds.patch" +SDP_BOUNDS_STAMP="$SOURCE_ROOT/.callwave-security-sdp-map-bounds" +if [[ ! -f "$SDP_BOUNDS_STAMP" ]]; then + git -C "$SOURCE_ROOT" apply "$SDP_BOUNDS_PATCH" + touch "$SDP_BOUNDS_STAMP" +fi + fetch_opus() { if [[ ! -f "$OPUS_TARBALL" ]]; then curl -fL --retry 3 -o "$OPUS_TARBALL" "$OPUS_URL" @@ -286,6 +330,8 @@ cat > "$BUILD_MANIFEST_PATH" <&2 exit 1 fi +if [[ ! -f "$BUILD_MANIFEST" ]]; then + echo "missing $BUILD_MANIFEST" >&2 + exit 1 +fi + +for security_commit in "${EXPECTED_SECURITY_COMMITS[@]}"; do + if ! grep -Fq "$security_commit" "$BUILD_MANIFEST"; then + echo "PJSIP manifest is missing security backport $security_commit" >&2 + exit 1 + fi +done +grep -Fq 'Patches/pjsip-2.17-sdp-map-bounds.patch' "$BUILD_MANIFEST" device_library="$FRAMEWORK/ios-arm64/libPJSIP.a" simulator_library="$FRAMEWORK/ios-arm64_x86_64-simulator/libPJSIP.a" @@ -62,4 +84,4 @@ if [[ "$device_all_symbols" != *"_cw_sha256_init"* ]]; then exit 1 fi -echo "PJSIP XCFramework: architectures, iOS floor, Apple TLS, Opus and SHA-256 verified" +echo "PJSIP XCFramework: architectures, iOS floor, Apple TLS, Opus, SHA-256 and security backports verified" diff --git a/Tests/CallWaveKitRegistryTests/CallWaveClientReliabilityTests.m b/Tests/CallWaveKitRegistryTests/CallWaveClientReliabilityTests.m index 858e682..de1542f 100644 --- a/Tests/CallWaveKitRegistryTests/CallWaveClientReliabilityTests.m +++ b/Tests/CallWaveKitRegistryTests/CallWaveClientReliabilityTests.m @@ -11,6 +11,7 @@ @interface CallWaveClient (ReliabilityTests) - (void)audioSessionWasInterrupted:(NSNotification *)notification; - (void)pushRegistry:(nullable PKPushRegistry *)registry didInvalidatePushTokenForType:(PKPushType)type; +- (BOOL)claimRuntimeWithError:(NSError **)error; @end @interface CallWaveTokenDelegate : NSObject @@ -83,4 +84,27 @@ - (void)testAudioRouteUsesPortTypesWithoutDeviceNames { XCTAssertEqualObjects(route.outputPortTypes, (@[AVAudioSessionPortBuiltInSpeaker])); } +- (void)testRuntimeOwnershipIsExclusiveBeforeTheFirstClientFinishesStarting { + CallWaveClient *first = [self makeClient]; + CallWaveClient *second = [self makeClient]; + NSError *firstError = nil; + NSError *secondError = nil; + + XCTAssertTrue([first claimRuntimeWithError:&firstError]); + XCTAssertNil(firstError); + XCTAssertFalse(first.isRunning, + @"this is the initialization window the ownership guard must cover"); + XCTAssertFalse([second claimRuntimeWithError:&secondError]); + XCTAssertEqual(secondError.code, CallWaveErrorEngineAlreadyRunning); + + // Releases the private claim even though no native runtime was created. + [first stop]; + + secondError = nil; + XCTAssertTrue([second claimRuntimeWithError:&secondError], + @"stop must release an initialization-stage owner"); + XCTAssertNil(secondError); + [second stop]; +} + @end diff --git a/Tests/CallWaveKitRegistryTests/CallWaveDeclineTeardownTests.m b/Tests/CallWaveKitRegistryTests/CallWaveDeclineTeardownTests.m index 2119e4a..83e0e7d 100644 --- a/Tests/CallWaveKitRegistryTests/CallWaveDeclineTeardownTests.m +++ b/Tests/CallWaveKitRegistryTests/CallWaveDeclineTeardownTests.m @@ -40,9 +40,47 @@ - (void)callWaveClient:(CallWaveClient *)client } @end +@interface CallWaveDeclineLogProbe : NSObject +@property (nonatomic, strong) NSMutableArray *messages; +@end + +@implementation CallWaveDeclineLogProbe + +- (instancetype)init { + self = [super init]; + if (self) { + _messages = [NSMutableArray array]; + } + return self; +} + +- (void)callWaveDidLogMessage:(NSString *)message + level:(CallWaveLogLevel)level + category:(NSString *)category { + @synchronized (self) { + [self.messages addObject:message]; + } +} + +- (nullable NSString *)messageContaining:(NSString *)needle { + @synchronized (self) { + for (NSString *message in self.messages) { + if ([message containsString:needle]) { + return message; + } + } + } + return nil; +} + +@end + @interface CallWaveDeclineTeardownTests : XCTestCase @property (nonatomic, assign) int sock; @property (nonatomic, strong, nullable) CallWaveClient *client; +@property (nonatomic, strong) CallWaveDeclineLogProbe *logProbe; +@property (nonatomic, assign) CallWaveLogLevel previousLogLevel; +@property (nonatomic, assign) BOOL previousRedaction; @end @implementation CallWaveDeclineTeardownTests @@ -50,12 +88,22 @@ @implementation CallWaveDeclineTeardownTests - (void)setUp { [super setUp]; _sock = -1; + self.logProbe = [[CallWaveDeclineLogProbe alloc] init]; + self.previousLogLevel = CallWaveLog.level; + self.previousRedaction = CallWaveLog.isRedactingIdentifiers; + CallWaveLog.level = CallWaveLogLevelDebug; + CallWaveLog.redactsIdentifiers = YES; + CallWaveLog.logger = self.logProbe; } - (void)tearDown { if (_sock >= 0) { close(_sock); _sock = -1; } [_client stop]; _client = nil; + CallWaveLog.logger = nil; + CallWaveLog.level = self.previousLogLevel; + CallWaveLog.redactsIdentifiers = self.previousRedaction; + self.logProbe = nil; [super tearDown]; } @@ -119,13 +167,25 @@ - (nullable NSString *)waitForResponseContaining:(NSString *)needle return nil; } +- (nullable NSString *)waitForLogContaining:(NSString *)needle + within:(NSTimeInterval)seconds { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:seconds]; + do { + NSString *message = [self.logProbe messageContaining:needle]; + if (message != nil) { return message; } + [NSRunLoop.currentRunLoop runUntilDate: + [NSDate dateWithTimeIntervalSinceNow:0.01]]; + } while (deadline.timeIntervalSinceNow > 0); + return nil; +} + - (NSString *)inviteFromPort:(int)from toPort:(int)to callId:(NSString *)callId { NSString *body = @"v=0\r\no=door 1 1 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\n" @"t=0 0\r\nm=audio 40002 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n"; return [NSString stringWithFormat: @"INVITE sip:1001@127.0.0.1 SIP/2.0\r\n" - @"Via: SIP/2.0/UDP 127.0.0.1:%d;branch=z9hG4bK-callwave-%@;rport\r\n" + @"Via: SIP/2.0/UDP 127.0.0.1:%d;branch=z9hG4bK-callwave-invite;rport\r\n" @"Max-Forwards: 70\r\n" @"From: \"Front door\" ;tag=doortag\r\n" @"To: \r\n" @@ -134,7 +194,7 @@ - (NSString *)inviteFromPort:(int)from toPort:(int)to callId:(NSString *)callId @"Contact: \r\n" @"Content-Type: application/sdp\r\n" @"Content-Length: %lu\r\n\r\n%@", - from, callId, callId, from, (unsigned long)body.length, body]; + from, callId, from, (unsigned long)body.length, body]; } - (CallWaveClient *)startedClient { @@ -145,11 +205,14 @@ - (CallWaveClient *)startedClient { username:@"1001" password:@"not-a-real-credential" includesCallsInRecents:NO]; + CallWaveEngineConfiguration *engineConfiguration = + [CallWaveEngineConfiguration defaultConfiguration]; + engineConfiguration.logLevel = CallWaveLogLevelDebug; CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:configuration options:CallWaveIntegrationOptionNone provider:nil - engineConfiguration:nil]; + engineConfiguration:engineConfiguration]; NSError *error = nil; if (![client startWithError:&error]) { XCTFail(@"engine did not start: %@", error); @@ -184,7 +247,10 @@ - (void)testDecliningARingingCallPutsTheFinalResponseOnTheWireAndWaitsForItsACK delegate.ringing = [self expectationWithDescription:@"ringing"]; self.client.delegate = delegate; - NSString *callId = @"callwave-decline-1"; + NSString *callId = [@"callwave-decline-" + stringByPaddingToLength:PJSIP_MAX_URL_SIZE + 32 + withString:@"x" + startingAtIndex:0]; [self send:[self inviteFromPort:localPort toPort:enginePort callId:callId] to:enginePort]; @@ -206,11 +272,28 @@ - (void)testDecliningARingingCallPutsTheFinalResponseOnTheWireAndWaitsForItsACK XCTAssertNotNil(decline, @"the 603 never reached the wire"); XCTAssertTrue([decline containsString:callId]); + NSString *sentLog = [self.logProbe messageContaining:@"603 sent to"]; + XCTAssertNotNil(sentLog); + XCTAssertTrue([sentLog containsString:@""], + @"the destination and Call-ID should respect identifier redaction"); + XCTAssertFalse([sentLog containsString:callId]); + XCTAssertFalse([sentLog containsString:@"127.0.0.1"]); + // And it is tracked until the peer acknowledges it — the drain condition // that pjsua_call_get_count() cannot express. XCTAssertEqual(CallWaveTeardownPendingFinalResponses(), 1u, @"an unACKed final response has to hold the drain open"); + // PJSIP retransmits a final response until the ACK arrives. The first 603 + // above and the ACK below already exercise the real UDP loopback. Use the + // observer event as the retransmission barrier here: UDP may legally drop + // an individual datagram on a loaded CI runner, while the behavior under + // test is whether every outgoing attempt reuses the same tracking slot. + XCTAssertNotNil([self waitForLogContaining:@"603 retransmitted" within:8], + @"PJSIP did not attempt to retransmit the unACKed 603"); + XCTAssertEqual(CallWaveTeardownPendingFinalResponses(), 1u, + @"a retransmission must reuse the original tracking slot"); + // The reason the 0.6.0 drain missed this path, pinned as an assertion: // PJSUA has already forgotten the call while its final response is still // unacknowledged, so a drain keyed on this number returns immediately and @@ -222,13 +305,13 @@ - (void)testDecliningARingingCallPutsTheFinalResponseOnTheWireAndWaitsForItsACK [self send:[NSString stringWithFormat: @"ACK sip:1001@127.0.0.1 SIP/2.0\r\n" - @"Via: SIP/2.0/UDP 127.0.0.1:%d;branch=z9hG4bK-callwave-%@;rport\r\n" + @"Via: SIP/2.0/UDP 127.0.0.1:%d;branch=z9hG4bK-callwave-invite;rport\r\n" @"Max-Forwards: 70\r\n" @"From: \"Front door\" ;tag=doortag\r\n" @"To: \r\n" @"Call-ID: %@\r\n" @"CSeq: 1 ACK\r\n" - @"Content-Length: 0\r\n\r\n", localPort, callId, callId] + @"Content-Length: 0\r\n\r\n", localPort, callId] to:enginePort]; NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5]; diff --git a/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift b/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift index a8ad368..fe95006 100644 --- a/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift +++ b/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift @@ -107,6 +107,43 @@ final class CallWavePushCancellationTests: XCTestCase { XCTAssertEqual(states().filter { $0 == .incoming }.count, 1) } + func testMalformedBuiltInPayloadFieldsFallBackInsteadOfCrashing() { + push(["data": ["uuid": NSNull(), "callerID": NSNull()]]) + + XCTAssertEqual(client.callState, .incoming) + XCTAssertNotNil(client.currentCallUUID) + XCTAssertEqual(client.currentCaller, "Unknown") + } + + func testMalformedPreferredFieldsDoNotHideValidFallbacks() { + let uuid = UUID() + push([ + "uuid": uuid.uuidString, + "caller_id": "top-level", + "data": [ + "uuid": "not-a-uuid", + "callerID": NSNull(), + "caller": "nested-fallback" + ] + ]) + + XCTAssertEqual(client.currentCallUUID, uuid) + XCTAssertEqual(client.currentCaller, "nested-fallback") + } + + func testMalformedNestedCancellationMarkerDoesNotHideTopLevelMarker() { + let uuid = UUID() + push(incomingPayload(uuid: uuid)) + push([ + "uuid": uuid.uuidString, + "type": "CANCEL", + "data": ["type": NSNull()] + ]) + + XCTAssertEqual(client.callState, .ended) + XCTAssertEqual(events.first { $0.type == .callEnded }?.callUUID, uuid) + } + func testCustomParserCanMarkAPayloadAsCancellation() { let uuid = UUID() client.pushPayloadParser = { payload in diff --git a/Vendor/PJSIP-BUILD.txt b/Vendor/PJSIP-BUILD.txt index b2c6ca2..13ed801 100644 --- a/Vendor/PJSIP-BUILD.txt +++ b/Vendor/PJSIP-BUILD.txt @@ -1,6 +1,8 @@ PJSIP version: 2.17 PJSIP commit: 5a457451fa2712ba18e12b01738e8ff3af2b26fd PJSIP patches: Patches/sip-auth-client-sha256.patch (SHA-256 digest without OpenSSL) +PJSIP security backports: acc03b57cef7a7d31b8e1f5b9117437d7e87c591 a1b707c0c9b0506faf2a8a438b60f11ffd6a6fd9 d6a0e7f76611c3a6f530ee051e3e7a622bb1748c 8d5956afab2ede95ddb199078dc19a8ac0114f3d 628b71638465bacf66e767959e6acbab822eccd6 082948b0a2ed658229fc6a50e475b411c69b0d2a fd9074547f4740de86548076c36d8d25be51fab3 +PJSIP adapted security patch: Patches/pjsip-2.17-sdp-map-bounds.patch (GHSA-rfwg-w9gq-9mw2) Opus version: 1.5.2 Minimum iOS: 15.0 Architectures: iphoneos/arm64, iphonesimulator/arm64+x86_64 diff --git a/Vendor/PJSIP.xcframework/Info.plist b/Vendor/PJSIP.xcframework/Info.plist index ea6d2f5..44a3c30 100644 --- a/Vendor/PJSIP.xcframework/Info.plist +++ b/Vendor/PJSIP.xcframework/Info.plist @@ -10,15 +10,18 @@ HeadersPath Headers LibraryIdentifier - ios-arm64 + ios-arm64_x86_64-simulator LibraryPath libPJSIP.a SupportedArchitectures arm64 + x86_64 SupportedPlatform ios + SupportedPlatformVariant + simulator BinaryPath @@ -26,18 +29,15 @@ HeadersPath Headers LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath libPJSIP.a SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator CFBundlePackageType diff --git a/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a b/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a index a4154a9..41b0808 100644 Binary files a/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a and b/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a differ diff --git a/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a b/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a index e52860d..a11df8e 100644 Binary files a/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a and b/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a differ