diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index f986e3c13b5..fd0f7ea3e27 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -102,6 +102,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 * @@ -115,12 +137,16 @@ 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)] 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('')) { @@ -130,6 +156,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; @@ -157,9 +191,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 2c89b849cf5..de2c81ca98f 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -18768,6 +18768,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 35f0ddf5a1b..8dce8e03d31 100644 --- a/openapi.json +++ b/openapi.json @@ -18673,6 +18673,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 304555e2e49..61de050b8d6 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -9677,6 +9677,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 97ecea71e2f..b02da8c9848 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -9139,6 +9139,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 811f7ef5502..96372e3a280 100644 --- a/tests/php/Controller/SignalingControllerTest.php +++ b/tests/php/Controller/SignalingControllerTest.php @@ -29,6 +29,7 @@ use OCA\Talk\Signaling\Messages; 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; @@ -1447,4 +1448,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()); + } }