Merge branch '1.9.x' of https://github.com/appwrite/appwrite into delete-project-shared-table-v1

This commit is contained in:
fogelito
2026-04-14 08:05:44 +03:00
9 changed files with 420 additions and 15 deletions
+19
View File
@@ -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;
}
}
@@ -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,
]);
@@ -72,6 +72,8 @@ class Get extends Action
throw new Exception(Exception::WEBHOOK_NOT_FOUND);
}
$webhook->removeAttribute('signatureKey');
$response->dynamic($webhook, Response::MODEL_WEBHOOK);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature;
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
@@ -15,6 +15,8 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
class Update extends Action
{
@@ -22,7 +24,7 @@ class Update extends Action
public static function getName()
{
return 'updateWebhookSignature';
return 'updateWebhookSecret';
}
public function __construct()
@@ -52,6 +54,7 @@ class Update extends Action
]
))
->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));
@@ -118,6 +118,8 @@ class Update extends Action
$queueForEvents->setParam('webhookId', $webhook->getId());
$webhook->removeAttribute('signatureKey');
$response->dynamic($webhook, Response::MODEL_WEBHOOK);
}
}
@@ -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,
@@ -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());
}
}
@@ -71,7 +71,7 @@ class Webhook extends Model
])
->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',
])
+379 -8
View File
@@ -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');
@@ -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 IS returned on creation
$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->assertEquals('my-custom-secret', $webhook['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $webhook['body']);
// Get webhook — secret must not be exposed
$get = $this->getWebhook($webhookId);
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertEmpty($get['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $get['body']);
// 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->assertEmpty($item['secret']);
$this->assertArrayNotHasKey('signatureKey', $item);
}
// Update webhook — secret must not be exposed
$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->assertEmpty($updated['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $updated['body']);
// Update webhook secret — secret IS returned on rotation
$rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key');
$this->assertEquals(200, $rotated['headers']['status-code']);
$this->assertEquals('rotated-secret-key', $rotated['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $rotated['body']);
// Cleanup
$this->deleteWebhook($webhookId);
}
// URL validation tests
public function testCreateWebhookWithPrivateDomain(): void
@@ -883,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',
@@ -902,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);
}
@@ -937,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']);
@@ -1645,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);
@@ -1779,7 +2142,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 +2163,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 +2204,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;
}