From 3263133e5f7332e67a1641bba38e52e520270dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:50:06 +0200 Subject: [PATCH 1/7] Implement secure webhook interfaces --- .../Platform/Modules/Webhooks/Http/Webhooks/Create.php | 5 ++++- .../Webhooks/Http/Webhooks/Signature/Update.php | 10 +++++++--- .../Platform/Modules/Webhooks/Services/Http.php | 4 ++-- src/Appwrite/Utopia/Response/Model/Webhook.php | 8 +++++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index e60810417c..e192dff2a0 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; use Utopia\Validator\Multiple; +use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\URL; @@ -68,6 +69,7 @@ class Create extends Action ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -88,6 +90,7 @@ class Create extends Action bool $tls, string $authUsername, string $authPassword, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -107,7 +110,7 @@ class Create extends Action 'security' => $tls, 'httpUser' => $authUsername, 'httpPass' => $authPassword, - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index e36fa5c2ce..fbff94735e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -1,6 +1,6 @@ param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform']) + ->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -62,6 +65,7 @@ class Update extends Action public function action( string $webhookId, + ?string $secret, Response $response, Document $project, QueueEvent $queueForEvents, @@ -78,7 +82,7 @@ class Update extends Action } $updates = new Document([ - 'signatureKey' => \bin2hex(\random_bytes(64)), + 'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)), ]); $webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates)); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php index 4805de6ebc..0e9c39a762 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Services/Http.php @@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook; -use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature; +use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook; use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks; use Utopia\Platform\Service; @@ -26,6 +26,6 @@ class Http extends Service $this->addAction(GetWebhook::getName(), new GetWebhook()); $this->addAction(DeleteWebhook::getName(), new DeleteWebhook()); $this->addAction(UpdateWebhook::getName(), new UpdateWebhook()); - $this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature()); + $this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret()); } } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index c2a2c183b1..25c2c21d4a 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,12 +69,16 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) + /* + Not exposed for security; Secret is currently write-only. + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) + */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -106,7 +110,9 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Would be 'secret', but we want it to be write-only. + // $document->setAttribute('secret', $document->getAttribute('signatureKey')); + // Remove DB-level attribute, just to be sure. $document->removeAttribute('signatureKey'); return $document; From a941d8b8557fd5bc1339ba0cc4b2d1f66d996c12 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:42:38 +0100 Subject: [PATCH 2/7] fix: trim execution queue payload to project ID only The v1-executions queue was serialising the full project document (OAuth providers, webhooks, keys, auths config, permissions, etc.) into every message. The worker DI system already re-fetches the complete project from the platform database using only the project ID, so the full document was wasted bytes. Override trimPayload() in the Execution event class to include only $id in the project stub, reducing message size significantly. Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Event/Execution.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Appwrite/Event/Execution.php b/src/Appwrite/Event/Execution.php index 398025565c..9e735991ba 100644 --- a/src/Appwrite/Event/Execution.php +++ b/src/Appwrite/Event/Execution.php @@ -53,4 +53,23 @@ class Execution extends Event 'execution' => $this->execution, ]; } + + /** + * Trim payload for the execution event. + * Only the project ID is needed — the worker DI fetches the full project from the platform database. + * + * @return array + */ + protected function trimPayload(): array + { + $trimmed = []; + + if ($this->project) { + $trimmed['project'] = new Document([ + '$id' => $this->project->getId(), + ]); + } + + return $trimmed; + } } From 28d285d5c5025416448beb3f27453f577073789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:06 +0200 Subject: [PATCH 3/7] Improved tests for webhook edge cases --- tests/e2e/Services/Webhooks/WebhooksBase.php | 359 ++++++++++++++++++- 1 file changed, 356 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 82181aa858..fc75a3ede9 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -708,6 +708,351 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } + public function testSecretRotationZeroDowntime(): void + { + // Create webhook pointing to request-catcher so deliveries are captured + $webhook = $this->createWebhook( + ID::unique(), + 'Rotation Test Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $originalSecret = $webhook['body']['secret']; + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); + + // Step 1: Trigger user creation with the original auto-generated secret + $email1 = uniqid() . 'rotation1@localhost.test'; + $user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email1, + 'password' => 'password', + 'name' => 'Rotation User 1', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + $userId1 = $user1['body']['$id']; + + // Verify webhook delivery is signed with the original secret + $this->assertEventually(function () use ($userId1, $originalSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId1) { + $this->assertStringContainsString( + "users.{$userId1}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Step 2: Rotate the secret to a known custom value + $newSecret = 'new-key-after-rotation'; + $updated = $this->updateWebhookSecret($webhookId, $newSecret); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($newSecret, $updated['body']['secret']); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); + + // Step 3: Trigger another user creation — should be signed with the new secret + $email2 = uniqid() . 'rotation2@localhost.test'; + $user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email2, + 'password' => 'password', + 'name' => 'Rotation User 2', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + $userId2 = $user2['body']['$id']; + + // Verify webhook delivery is signed with the new rotated secret + $this->assertEventually(function () use ($userId2, $newSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId2) { + $this->assertStringContainsString( + "users.{$userId2}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookWithCustomSecret(): void + { + $customSecret = 'custom-secret-key'; + + // Create webhook with a custom secret pointing to request-catcher + $webhook = $this->createWebhook( + ID::unique(), + 'Custom Secret Webhook', + ['users.*.create'], + null, + 'http://request-catcher-webhook:5000/', + false, + null, + null, + $customSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertEquals($customSecret, $webhook['body']['secret']); + + // Trigger user creation to generate a webhook delivery + $email = uniqid() . 'customsecret@localhost.test'; + $user = $this->client->call(Client::METHOD_POST, '/users', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'Custom Secret User', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + // Verify webhook delivery is signed with the custom secret + $this->assertEventually(function () use ($userId, $customSecret) { + $delivery = $this->getLastRequest(function (array $request) use ($userId) { + $this->assertStringContainsString( + "users.{$userId}.create", + $request['headers']['X-Appwrite-Webhook-Events'] ?? '' + ); + }); + + $this->assertNotEmpty($delivery); + $payload = json_encode($delivery['data']); + $url = $delivery['url']; + $signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true)); + $this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']); + }, 15000, 500); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testCreateWebhookSecretMinLength(): void + { + // 7 chars — below minimum of 8 + $webhook = $this->createWebhook( + ID::unique(), + 'Short Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'short12' + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + + // 8 chars — exactly at minimum + $webhook = $this->createWebhook( + ID::unique(), + 'Min Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'exact8ch' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals('exact8ch', $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testCreateWebhookSecretMaxLength(): void + { + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $webhook = $this->createWebhook( + ID::unique(), + 'Max Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $maxSecret + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $this->assertEquals($maxSecret, $webhook['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $webhook = $this->createWebhook( + ID::unique(), + 'Too Long Secret Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + $tooLongSecret + ); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookSecretMinLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Min Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 7 chars — below minimum of 8 + $updated = $this->updateWebhookSecret($webhookId, 'short12'); + $this->assertEquals(400, $updated['headers']['status-code']); + + // 8 chars — exactly at minimum + $updated = $this->updateWebhookSecret($webhookId, 'exact8ch'); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('exact8ch', $updated['body']['secret']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretMaxLength(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Max Update Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // 256 chars — exactly at maximum + $maxSecret = str_repeat('a', 256); + $updated = $this->updateWebhookSecret($webhookId, $maxSecret); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($maxSecret, $updated['body']['secret']); + + // 257 chars — above maximum + $tooLongSecret = str_repeat('a', 257); + $updated = $this->updateWebhookSecret($webhookId, $tooLongSecret); + $this->assertEquals(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testWebhookSecretNotExposedInResponses(): void + { + // Create webhook — secret must not leak in creation response + $webhook = $this->createWebhook( + ID::unique(), + 'Secret Exposure Test', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null, + 'my-custom-secret' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertArrayNotHasKey('signatureKey', $webhook['body']); + + // Get webhook — secret must not leak + $get = $this->getWebhook($webhookId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertArrayNotHasKey('signatureKey', $get['body']); + + // List webhooks — secret must not leak + $list = $this->listWebhooks(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + foreach ($list['body']['webhooks'] as $item) { + $this->assertArrayNotHasKey('secret', $item); + $this->assertArrayNotHasKey('signatureKey', $item); + } + + // Update webhook — secret must not leak + $updated = $this->updateWebhook( + $webhookId, + 'Secret Exposure Test Updated', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('signatureKey', $updated['body']); + + // Update webhook secret — secret must not leak + $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); + $this->assertEquals(200, $rotated['headers']['status-code']); + $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertArrayNotHasKey('signatureKey', $rotated['body']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // URL validation tests public function testCreateWebhookWithPrivateDomain(): void @@ -1779,7 +2124,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword, ?string $secret = null): mixed { $params = [ 'webhookId' => $webhookId, @@ -1800,6 +2145,9 @@ trait WebhooksBase if ($authPassword !== null) { $params['authPassword'] = $authPassword; } + if ($secret !== null) { + $params['secret'] = $secret; + } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ 'content-type' => 'application/json', @@ -1838,12 +2186,17 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSecret(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed { + $params = []; + if ($secret !== null) { + $params['secret'] = $secret; + } + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ], $this->getHeaders()), $params); return $webhook; } From 9f1ec356d13fe0d52425c7ff74ed82d5d5cfc926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:53:25 +0200 Subject: [PATCH 4/7] Formatting fix --- src/Appwrite/Utopia/Response/Model/Webhook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 25c2c21d4a..70e8ef9842 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -71,7 +71,7 @@ class Webhook extends Model ]) /* Not exposed for security; Secret is currently write-only. - + ->addRule('secret', [ 'type' => self::TYPE_STRING, 'description' => 'Signature key which can be used to validate incoming webhook payloads.', From c9fceb870c707cd5442dffe2d2424a89094015a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 14:57:24 +0200 Subject: [PATCH 5/7] Fix folder structure --- .../Webhooks/Http/Webhooks/{Signature => Secret}/Update.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/{Signature => Secret}/Update.php (100%) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php similarity index 100% rename from src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php rename to src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Secret/Update.php From 2585518e334041743256bd3a8a10388418900c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:13 +0200 Subject: [PATCH 6/7] Fix bugs --- .../Platform/Modules/Webhooks/Http/Webhooks/Get.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/Update.php | 2 ++ .../Platform/Modules/Webhooks/Http/Webhooks/XList.php | 4 ++++ src/Appwrite/Utopia/Response/Model/Webhook.php | 10 ++-------- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php index ebe6fa7bcb..a42500ca46 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php @@ -72,6 +72,8 @@ class Get extends Action throw new Exception(Exception::WEBHOOK_NOT_FOUND); } + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php index 6085eb74b2..e7b516449e 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Update.php @@ -118,6 +118,8 @@ class Update extends Action $queueForEvents->setParam('webhookId', $webhook->getId()); + $webhook->removeAttribute('signatureKey'); + $response->dynamic($webhook, Response::MODEL_WEBHOOK); } } diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 763c0d339b..f0961b541c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -120,6 +120,10 @@ class XList extends Action throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); } + foreach ($webhooks as $webhook) { + $webhook->removeAttribute('signatureKey'); + } + $response->dynamic(new Document([ 'webhooks' => $webhooks, 'total' => $total, diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 70e8ef9842..6a0197e4a1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -69,16 +69,12 @@ class Webhook extends Model 'default' => '', 'example' => 'password', ]) - /* - Not exposed for security; Secret is currently write-only. - ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validate incoming webhook payloads.', + 'description' => 'Signature key which can be used to validate incoming webhook payloads. Only returned on creation and secret rotation.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) - */ ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Indicates if this webhook is enabled.', @@ -110,9 +106,7 @@ class Webhook extends Model $document->setAttribute('authPassword', $document->getAttribute('httpPass')); $document->removeAttribute('httpPass'); - // Would be 'secret', but we want it to be write-only. - // $document->setAttribute('secret', $document->getAttribute('signatureKey')); - // Remove DB-level attribute, just to be sure. + $document->setAttribute('secret', $document->getAttribute('signatureKey')); $document->removeAttribute('signatureKey'); return $document; From db406b0a275bbafa13bd5aee2d66541ff6f2de8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Apr 2026 15:08:20 +0200 Subject: [PATCH 7/7] Fix tests --- tests/e2e/Services/Webhooks/WebhooksBase.php | 48 ++++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index fc75a3ede9..f926a97c81 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -694,10 +694,10 @@ trait WebhooksBase $this->assertEquals(128, \strlen($updated['body']['secret'])); $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new secret persisted via GET + // Verify secret is not exposed via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSecret, $get['body']['secret']); + $this->assertEmpty($get['body']['secret']); // Test secret update on non-existent webhook $notFound = $this->updateWebhookSecret('non-existent-id'); @@ -996,7 +996,7 @@ trait WebhooksBase public function testWebhookSecretNotExposedInResponses(): void { - // Create webhook — secret must not leak in creation response + // Create webhook — secret IS returned on creation $webhook = $this->createWebhook( ID::unique(), 'Secret Exposure Test', @@ -1011,24 +1011,24 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $this->assertArrayNotHasKey('secret', $webhook['body']); + $this->assertEquals('my-custom-secret', $webhook['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $webhook['body']); - // Get webhook — secret must not leak + // Get webhook — secret must not be exposed $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $get['body']); + $this->assertEmpty($get['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $get['body']); - // List webhooks — secret must not leak + // List webhooks — secret must not be exposed $list = $this->listWebhooks(null, true); $this->assertEquals(200, $list['headers']['status-code']); foreach ($list['body']['webhooks'] as $item) { - $this->assertArrayNotHasKey('secret', $item); + $this->assertEmpty($item['secret']); $this->assertArrayNotHasKey('signatureKey', $item); } - // Update webhook — secret must not leak + // Update webhook — secret must not be exposed $updated = $this->updateWebhook( $webhookId, 'Secret Exposure Test Updated', @@ -1040,13 +1040,13 @@ trait WebhooksBase null ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertEmpty($updated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $updated['body']); - // Update webhook secret — secret must not leak + // Update webhook secret — secret IS returned on rotation $rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key'); $this->assertEquals(200, $rotated['headers']['status-code']); - $this->assertArrayNotHasKey('secret', $rotated['body']); + $this->assertEquals('rotated-secret-key', $rotated['body']['secret']); $this->assertArrayNotHasKey('signatureKey', $rotated['body']); // Cleanup @@ -1228,6 +1228,12 @@ trait WebhooksBase { $customId = 'my-custom-webhook-id'; + // Clean up stale webhook from a previous run if it exists + $existing = $this->getWebhook($customId); + if ($existing['headers']['status-code'] === 200) { + $this->deleteWebhook($customId); + } + $webhook = $this->createWebhook( $customId, 'Custom ID Webhook', @@ -1247,6 +1253,19 @@ trait WebhooksBase $this->assertEquals(200, $get['headers']['status-code']); $this->assertEquals($customId, $get['body']['$id']); + // Ensure duplicate creation fails + $duplicate = $this->createWebhook( + $customId, + 'Duplicate Custom ID Webhook', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + $this->assertEquals(409, $duplicate['headers']['status-code']); + // Cleanup $this->deleteWebhook($customId); } @@ -1282,8 +1301,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['tls']); $this->assertEquals('myuser', $get['body']['authUsername']); $this->assertEquals('mypass', $get['body']['authPassword']); - $this->assertNotEmpty($get['body']['secret']); - $this->assertEquals(128, \strlen($get['body']['secret'])); + $this->assertEmpty($get['body']['secret']); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1990,7 +2008,7 @@ trait WebhooksBase $this->assertEquals(true, $get['body']['security']); $this->assertEquals('getuser', $get['body']['httpUser']); $this->assertEquals('getpass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); + $this->assertEmpty($get['body']['signatureKey']); // Cleanup $this->deleteWebhook($webhookId);