diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index 6785aeb487a..1896a8109f8 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -105,6 +105,28 @@ private function validateRecordingBackendRequest(string $data): bool { } } + /** + * Check if the current request is coming from an allowed SIP bridge. + * + * The bridge sends the custom header "Talk-SIPBridge-Random" containing + * at least 32 bytes random data, and the header "Talk-SIPBridge-Checksum", + * which is the SHA256-HMAC of the random data and the room token, + * calculated with the shared secret from the configuration. + * + * @param string $data Room token (or empty string when no token is present) + * @return bool + */ + private function validateSIPBridgeRequest(string $data): bool { + $random = $this->request->getHeader('talk-sipbridge-random'); + $checksum = $this->request->getHeader('talk-sipbridge-checksum'); + $secret = $this->talkConfig->getSIPSharedSecret(); + try { + return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $data); + } catch (UnauthorizedException) { + return false; + } + } + /** * Get the signaling settings * @@ -118,15 +140,19 @@ private function validateRecordingBackendRequest(string $data): bool { #[PublicPage] #[BruteForceProtection(action: 'talkRoomToken')] #[BruteForceProtection(action: 'talkRecordingSecret')] + #[BruteForceProtection(action: 'talkSipBridgeSecret')] #[BruteForceProtection(action: 'talkFederationAccess')] #[OpenAPI(tags: ['internal_signaling', 'external_signaling'])] #[RequestHeader(name: 'talk-recording-random', description: 'Random seed used to generate the request checksum', indirect: true)] #[RequestHeader(name: 'talk-recording-checksum', description: 'Checksum over the request body to verify authenticity from the recording backend', indirect: true)] + #[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed used to generate the request checksum', indirect: true)] + #[RequestHeader(name: 'talk-sipbridge-checksum', description: 'Checksum over the room token to verify authenticity from the SIP bridge', indirect: true)] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/signaling/settings', requirements: [ 'apiVersion' => '(v3)', ])] public function getSettings(string $token = ''): DataResponse { $isRecordingRequest = false; + $isSIPBridgeRequest = false; if (!empty($this->request->getHeader('talk-recording-random')) || !empty($this->request->getHeader('talk-recording-checksum'))) { if (!$this->validateRecordingBackendRequest('')) { @@ -136,6 +162,14 @@ public function getSettings(string $token = ''): DataResponse { } $isRecordingRequest = true; + } elseif (!empty($this->request->getHeader('talk-sipbridge-random')) || !empty($this->request->getHeader('talk-sipbridge-checksum'))) { + if (!$this->validateSIPBridgeRequest($token)) { + $response = new DataResponse(null, Http::STATUS_UNAUTHORIZED); + $response->throttle(['action' => 'talkSipBridgeSecret']); + return $response; + } + + $isSIPBridgeRequest = true; } elseif ($this->serverSession->get('app_api') === true) { // Live transcription ex-app $isRecordingRequest = true; @@ -171,9 +205,9 @@ public function getSettings(string $token = ''): DataResponse { $this->federationAuthenticator->authenticated($room, $participant); } elseif ($token !== '') { $room = $this->manager->getRoomForUserByToken($token, $this->userId); - } elseif ($this->userId !== null || $isRecordingRequest) { + } elseif ($this->userId !== null || $isRecordingRequest || $isSIPBridgeRequest) { // Mobile clients and admin setup check use the neutral point - // Same for live-transcription + // Same for live-transcription and SIP bridge $room = null; } else { throw new RoomNotFoundException(); diff --git a/openapi-full.json b/openapi-full.json index 69333168bfd..3cddb5c40ab 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -27100,6 +27100,22 @@ "type": "string" } }, + { + "name": "talk-sipbridge-random", + "in": "header", + "description": "Random seed used to generate the request checksum", + "schema": { + "type": "string" + } + }, + { + "name": "talk-sipbridge-checksum", + "in": "header", + "description": "Checksum over the room token to verify authenticity from the SIP bridge", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/openapi.json b/openapi.json index 922e5a6af4d..88a70a97ff4 100644 --- a/openapi.json +++ b/openapi.json @@ -26988,6 +26988,22 @@ "type": "string" } }, + { + "name": "talk-sipbridge-random", + "in": "header", + "description": "Random seed used to generate the request checksum", + "schema": { + "type": "string" + } + }, + { + "name": "talk-sipbridge-checksum", + "in": "header", + "description": "Checksum over the room token to verify authenticity from the SIP bridge", + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 1792462f6bf..886c5f18953 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -13668,6 +13668,10 @@ export interface operations { "talk-recording-random"?: string; /** @description Checksum over the request body to verify authenticity from the recording backend */ "talk-recording-checksum"?: string; + /** @description Random seed used to generate the request checksum */ + "talk-sipbridge-random"?: string; + /** @description Checksum over the room token to verify authenticity from the SIP bridge */ + "talk-sipbridge-checksum"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index da943e078f9..29e96eaf5bb 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -13101,6 +13101,10 @@ export interface operations { "talk-recording-random"?: string; /** @description Checksum over the request body to verify authenticity from the recording backend */ "talk-recording-checksum"?: string; + /** @description Random seed used to generate the request checksum */ + "talk-sipbridge-random"?: string; + /** @description Checksum over the room token to verify authenticity from the SIP bridge */ + "talk-sipbridge-checksum"?: string; /** @description Required to be true for the API request to pass */ "OCS-APIRequest": boolean; }; diff --git a/tests/php/Controller/SignalingControllerTest.php b/tests/php/Controller/SignalingControllerTest.php index 6e294cede3d..ce414f2e651 100644 --- a/tests/php/Controller/SignalingControllerTest.php +++ b/tests/php/Controller/SignalingControllerTest.php @@ -30,6 +30,7 @@ use OCA\Talk\Signaling\RoomPropertiesHelper; use OCA\Talk\TalkSession; use OCP\App\IAppManager; +use OCP\AppFramework\Http; use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Config\IUserConfig; @@ -1449,4 +1450,85 @@ public function testLeaveRoomWithOldSession(): void { $participant = $participantService->getParticipant($room, $this->userId, $newSessionId); $this->assertEquals($newSessionId, $participant->getSession()->getSessionId()); } + + private const SIP_BRIDGE_SECRET = 'MySIPSecretValueMySIPSecretValue1234'; + + private function sipBridgeChecksum(string $data, string $random): string { + return hash_hmac('sha256', $random . $data, self::SIP_BRIDGE_SECRET); + } + + private function setUpSIPBridgeConfig(): void { + $this->config = $this->createMock(Config::class); + $this->config->method('getSIPSharedSecret')->willReturn(self::SIP_BRIDGE_SECRET); + $this->userId = null; + $this->recreateSignalingController(); + } + + public function testGetSettingsUnauthenticatedWithoutToken(): void { + $this->userId = null; + $this->recreateSignalingController(); + + $this->request->method('getHeader')->willReturn(''); + + $result = $this->controller->getSettings(); + $this->assertSame(Http::STATUS_NOT_FOUND, $result->getStatus()); + } + + public function testGetSettingsSIPBridgeInvalidChecksum(): void { + $this->setUpSIPBridgeConfig(); + + $random = 'afb6b872ab03e3376b31bf0af601067222ff7990335ca02d327071b73c0119c6'; + $this->request->method('getHeader') + ->willReturnCallback(fn (string $header): string => match ($header) { + 'talk-sipbridge-random' => $random, + 'talk-sipbridge-checksum' => 'invalid-checksum', + default => '', + }); + + $result = $this->controller->getSettings(); + $this->assertSame(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + } + + public function testGetSettingsSIPBridgeShortRandom(): void { + $this->setUpSIPBridgeConfig(); + + $random = 'tooshort'; + $checksum = $this->sipBridgeChecksum('', $random); + $this->request->method('getHeader') + ->willReturnCallback(fn (string $header): string => match ($header) { + 'talk-sipbridge-random' => $random, + 'talk-sipbridge-checksum' => $checksum, + default => '', + }); + + $result = $this->controller->getSettings(); + $this->assertSame(Http::STATUS_UNAUTHORIZED, $result->getStatus()); + } + + public function testGetSettingsSIPBridgeValidNoToken(): void { + $this->config = $this->createMock(Config::class); + $this->config->method('getSIPSharedSecret')->willReturn(self::SIP_BRIDGE_SECRET); + $this->config->method('getStunServers')->willReturn([]); + $this->config->method('getTurnSettings')->willReturn([]); + $this->config->method('getSignalingMode')->willReturn(Config::SIGNALING_INTERNAL); + $this->config->method('getHideSignalingWarning')->willReturn(false); + $this->config->method('isSIPConfigured')->willReturn(false); + $this->signalingManager->method('getSignalingServerLinkForConversation')->willReturn(''); + $this->userId = null; + $this->recreateSignalingController(); + + $random = 'afb6b872ab03e3376b31bf0af601067222ff7990335ca02d327071b73c0119c6'; + $checksum = $this->sipBridgeChecksum('', $random); + $this->request->method('getHeader') + ->willReturnCallback(fn (string $header): string => match ($header) { + 'talk-sipbridge-random' => $random, + 'talk-sipbridge-checksum' => $checksum, + default => '', + }); + + $this->serverSession->method('get')->willReturn(null); + + $result = $this->controller->getSettings(); + $this->assertSame(Http::STATUS_OK, $result->getStatus()); + } }