Skip to content
Back to skills

Openproxy Web Fetch

CSecurity

Fetch a web page through OpenProxy and get back extracted text. Use whenever the user wants to read a URL through the proxy, route page fetches to Firecrawl / Jina Reader / Tavily / Exa / Ollama, fall back across fetch accounts, or debug `POST /v1/web/fetch` — including the `/v1/fetch` path that the model kind map advertises but the router does not serve.

  • 26 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 2, 2026
ai-agentsgobashapi

Works with

  • cli
  • api

Security analysis

C63/100
  • criticalExfiltrates credentials via HTTP — exact pattern from Snyk ToxicSkills study
  • criticalSends environment variables or credentials to an external URL

Pro shows the line behind each finding and how to fix it

Scanned October 2, 2026

npx -y skills add quangdang46/openproxy --skill openproxy-web-fetch --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Openproxy Web Fetch?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Openproxy Web Fetch
[![Security: C — Skills Directory](https://www.skillsdirectory.com/api/skills/quangdang46-openproxy-web-fetch/badge)](https://www.skillsdirectory.com/skills/quangdang46-openproxy-web-fetch)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: openproxy-web-fetch
description: Fetch a web page through OpenProxy and get back extracted text. Use whenever the user wants to read a URL through the proxy, route page fetches to Firecrawl / Jina Reader / Tavily / Exa / Ollama, fall back across fetch accounts, or debug `POST /v1/web/fetch` — including the `/v1/fetch` path that the model kind map advertises but the router does not serve.
---

# openproxy — web fetch

`POST /v1/web/fetch` turns a URL into extracted text. It is the same router, the same
account-fallback loop, and the same combo machinery as `/v1/chat/completions`, aimed at
pages instead of prompts.

This skill covers:

1. The one real path (`POST /v1/web/fetch`) and the `/v1/fetch` that only exists in metadata
2. Registering a fetch provider
3. Calling it — curl and the `openproxy` CLI
4. The request/response contract
5. Combos and account fallback
6. Verifications and failure modes

## 1 · The path — `/v1/web/fetch`, not `/v1/fetch`

**The fetch route is `POST /v1/web/fetch`** (`src/server/api/web_fetch.rs:28-33`, mounted at
`src/server/api/mod.rs:114`). `GET /v1` advertises it correctly (`src/server/api/mod.rs:459`).

There is a real, confirmed discrepancy you should not fall into:

| Source | Value | Reality |
|---|---|---|
| `kind_endpoint("webFetch")` — `src/server/api/v1_models.rs:36` | `/v1/fetch` | **404.** No route is registered for it. |
| Route table in `src/server/api/web_fetch.rs:30` | `/v1/web/fetch` | The live path. |
| `GET /v1` index — `src/server/api/mod.rs:459` | `/v1/web/fetch` | The live path. |

`/v1/fetch` appears in exactly two places in the tree: the `kind_endpoint` arm and the unit
test that pins it (`v1_models.rs:1330`, `kind_endpoint_matches_9router`). Nothing serves it.

```bash
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:4623/v1/fetch \
    -H 'content-type: application/json' -d '{"provider":"firecrawl","url":"https://example.com"}'
404
```

The wrong value surfaces as the `endpoint` field of `GET /v1/models/info` (`v1_models.rs:889`).
It is **latent today, not user-visible**: no catalog model id is literally `fetch`, so
`catalog.find_model(provider, "fetch")` returns `None` and the merge block that emits
`endpoint` is skipped. Do not rely on it either way — call `/v1/web/fetch`.

Two other path facts:

- Only `POST` and `OPTIONS` are routed. `GET /v1/web/fetch` returns **405**.
- No `/v1/v1/web/fetch` double-prefix alias exists for this route (unlike chat, embeddings,
  search, and the other media routes). `/v1/web/fetch` is the only spelling.

## 2 · Register a fetch provider

Dispatch matches on the connection's `provider` field, so the value must be one of the
canonical ids below — a short alias is resolved on the *request* side only, never on the
stored row.

| Canonical id | Short alias | Upstream | Upstream body | Upstream auth header |
|---|---|---|---|---|
| `firecrawl` | `fc` | `https://api.firecrawl.dev/v1/scrape` | `{url, formats:[format]}` | `Authorization: Bearer <key>` |
| `jina-reader` | `jina` | `https://r.jina.ai/` | `{url}` | `Authorization: Bearer <key>` |
| `tavily` | `tv` | `https://api.tavily.com/extract` | `{urls:[url], extract_depth:"basic"}` | `Authorization: Bearer <key>` |
| `exa` | `exa` | `https://api.exa.ai/contents` | `{ids:[url], text:true}` | `x-api-key: <key>` |
| `ollama` | `ol` | `https://ollama.com/api/web_fetch` (overridable) | `{url}` | `Authorization: Bearer <key>` |

Anything else resolves to itself and fails at credential selection with
`400 {"error":"No credentials for provider: <id>"}` — the credential check runs *before* the
`Unsupported web fetch provider` check in `build_fetch_request`, so an unknown id reports
missing credentials, not an unsupported provider.

Add one with the normal provider command. Only `provider` and `name` are required:

```bash
export OPENPROXY_API_KEY="$(cat ~/.openproxy/admin.key)"

openproxy provider add firecrawl-main \
  '{"provider":"firecrawl","apiKey":"fc-…","isActive":true,"priority":0}'
```

Two constraints worth knowing:

- **`openproxy media providers add --kind webFetch` does not work.** The handler whitelists
  `["tts","stt","embedding","image","search"]` (`src/server/api/media_providers.rs:194`) and
  answers `400 Invalid media_type`. If you want the row to also appear in the dashboard's web
  list, add it with `--kind search` — `mediaType` does not affect fetch dispatch at all.
- **Self-hosted Ollama:** set `providerSpecificData.baseUrl` to override the upstream
  (`web_fetch.rs:487-493`). The target `url` is still SSRF-checked; the upstream is not.

## 3 · Call it

### curl

```bash
BASE=http://127.0.0.1:4623
export OPENPROXY_API_KEY="$(cat ~/.openproxy/admin.key)"

curl -sS -X POST "$BASE/v1/web/fetch" \
  -H "authorization: Bearer $OPENPROXY_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"provider":"firecrawl","url":"https://example.com"}'
```

```json
{
  "provider": "firecrawl",
  "url": "https://example.com",
  "title": "Example Domain",
  "content": { "format": "markdown", "text": "# Example Domain\n\n…", "length": 41 },
  "metadata": { "author": null, "published_at": null, "language": null },
  "usage": { "fetch_cost_usd": null },
  "metrics": { "response_time_ms": 1, "upstream_latency_ms": 0 }
}
```

Truncate the output, and ask for plain text:

```bash
curl -sS -X POST "$BASE/v1/web/fetch" \
  -H "authorization: Bearer $OPENPROXY_API_KEY" -H 'content-type: application/json' \
  -d '{"provider":"firecrawl","url":"https://example.com","format":"text","maxCharacters":2000}' \
  | jq -r '.content.text'
```

`provider` may also be sent as `model` (serde alias) — the dashboard sends `model` because for
this endpoint the provider *is* the model.

### CLI

```bash
openproxy media web fetch https://example.com --provider firecrawl
openproxy media web fetch https://example.com --provider firecrawl --format text --max-chars 2000
```

Against a remote server, add the global `--url` / `--api-key` pair (or `OPENPROXY_URL` /
`OPENPROXY_API_KEY`):

```bash
openproxy --url "$BASE" --api-key "$OPENPROXY_API_KEY" --robot \
  media web fetch https://example.com --provider firecrawl \
  | jq -r '.data.content.text'
```

**Always use `--robot` and read `.data.content.text`.** Without `--robot` the command prints
an empty line: `run_web_fetch` reads `payload["content"]` as a *string*
(`src/cli/media.rs:760`) while the server returns `content` as an *object* with a nested
`text`. `Value::as_str` yields `None` and it falls back to `""`. The robot envelope
(`openproxy.v1.media.web.fetch`) carries the whole payload and is unaffected — the CLI test
passes only because it mocks a flat `content` string, which is not the shape the real handler
emits.

## 4 · Contract

### Request

| Field | Type | Required | Notes |
|---|---|---|---|
| `provider` (alias `model`) | string | yes | Provider id, short alias, or a combo name. |
| `url` | string | yes | Parsed with `url::Url`; must survive the SSRF guard. |
| `format` | string | no | `markdown` (default) \| `html` \| `text`. Passed to the upstream, not enforced locally. |
| `maxCharacters` | usize | no | Truncates the extracted text; `content.length` reflects the cut. |

### Response

One normalized envelope for every provider (see `normalize_fetch_response`,
`web_fetch.rs:592-704`). `content.text` is the payload; `title` is populated where the
upstream exposes one (Firecrawl metadata, Jina's leading `Title:` line or `# ` heading, Exa
result title) and is `null` otherwise. `metadata.author` / `published_at` / `language` and
`usage.fetch_cost_usd` are always `null` placeholders today.

### Errors

Errors are a bare JSON string, **not** an object: `{"error":"<message>"}`.

| Status | When | Example message |
|---|---|---|
| 400 | missing / invalid / blocked `url` | `Missing required field: url`, `Invalid URL format` |
| 400 | missing provider | `Missing required field: provider (or model)` |
| 400 | no usable credential | `No credentials for provider: firecrawl` |
| 400 | SSRF guard | `URL resolves to a private/internal IP address: 127.0.0.1` |
| 401 | auth gate (see §5) | `{"error":{"code":"invalid_api_key","message":"Missing API key",…}}` |
| 404 | you used `/v1/fetch` | — |
| 405 | wrong method | — |
| 502 | client-pool or JSON parse failure | `JSON parse error: …` |
| 504 | upstream exceeded the 30 s timeout | `Request timed out` |
| upstream | non-2xx status and body are passed through | whatever the provider said |

### SSRF guard

`check_private_ip` (`web_fetch.rs:556-590`) rejects loopback, RFC1918, link-local,
unspecified, and multicast addresses for IPv4; loopback, unspecified, multicast, and
`fc00::/7` for IPv6. Hostnames are resolved with `lookup_host` first, so a public name
pointing at a private address is caught too. The guard runs on the **target `url` only** —
the configured upstream base URL is not checked.

## 5 · Auth

Auth is conditional on `settings.require_login` (`web_fetch.rs:76-82`), which **defaults to
`true`** (`src/types/mod.rs:771`) and can be turned off per install. On a default install a
bearer is required:

```bash
curl -sS -X POST "$BASE/v1/web/fetch" -H 'content-type: application/json' \
  -d '{"provider":"firecrawl","url":"https://example.com"}'
# → 401 {"error":{"code":"invalid_api_key","message":"Missing API key","type":"authentication_error"}}
```

Use the admin key (what `openproxy server init` emits) or any key from `openproxy key add`.
`GET /v1/models` needs no key, so discovery stays open even when fetch is gated.

CORS is wide open — `OPTIONS` answers 200 with `access-control-allow-origin: *`,
`allow-methods: *`, `allow-headers: *`, and every response carries `allow-origin: *`.

## 6 · Combos and fallback

`provider` may be a **combo name**, resolved before provider dispatch (`web_fetch.rs:115`).
Matching is by name only, so the name must contain no `/`, the combo must be active, and it
must have members. `kind` is irrelevant to dispatch, so a combo created with plain
`openproxy combo create` works even though the CLI never sets `kind`:

```bash
openproxy combo create --name webcombo --models "firecrawl,jina-reader" --strategy fallback

curl -sS -X POST "$BASE/v1/web/fetch" \
  -H "authorization: Bearer $OPENPROXY_API_KEY" -H 'content-type: application/json' \
  -d '{"provider":"webcombo","url":"https://example.com"}'
```

The combo strategy comes from `settings.comboStickyRoundRobinLimit` for sticky round-robin
(`web_fetch.rs:171-176`), matching chat's behaviour.

**Per-account fallback** is the same loop as chat: credentials are selected by lowest
`priority` among active connections with a non-empty `api_key` or `access_token`; a failed
attempt marks that connection unavailable (cooldown, backoff level, `test_status`) and
retries the next one; the first success clears the error state. `comboStickyRoundRobinLimit`
is honoured, unlike the older hardcoded `1`.

## 7 · Discovery

With an active connection to a catalogued fetch provider, the model list advertises the
synthetic `fetch` model:

```bash
curl -sS "$BASE/v1/models/web" | jq '.data[] | {id, kind}'
# → { "id": "firecrawl/fetch", "kind": "webFetch" }
```

`firecrawl`, `jina-reader`, `tavily`, and `exa` carry `webFetch` in the embedded catalog. On
an install with **no** active provider connections the list is empty — the catalog fallback
only enumerates `providerModels` entries, and the four fetch providers have none:

```bash
curl -sS "$BASE/v1/models/web"
# → {"object":"list","data":[]}
```

Register a provider first (§2) before expecting anything here.

## 8 · Verifications

```bash
# The real route is reached (400 = handler ran), the wrong one is not (404)
curl -s -o /dev/null -w 'wrong path:     %{http_code}\n' -X POST "$BASE/v1/fetch" \
  -H 'content-type: application/json' -d '{}'                  # 404
curl -s -o /dev/null -w 'web/fetch route: %{http_code}\n' -X POST "$BASE/v1/web/fetch" \
  -H "authorization: Bearer $OPENPROXY_API_KEY" -H 'content-type: application/json' -d '{}'  # 400

# Preflight
curl -s -o /dev/null -D - -X OPTIONS "$BASE/v1/web/fetch" | grep -i 'access-control'

# The advertised index
curl -sS "$BASE/v1" | jq '.endpoints' | grep web

# End to end through a real provider
curl -sS -X POST "$BASE/v1/web/fetch" \
  -H "authorization: Bearer $OPENPROXY_API_KEY" -H 'content-type: application/json' \
  -d '{"provider":"firecrawl","url":"https://example.com"}' | jq '.content.length'

# SSRF guard fires
curl -sS -X POST "$BASE/v1/web/fetch" -H "authorization: Bearer $OPENPROXY_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"provider":"firecrawl","url":"http://127.0.0.1:8080/"}'
# → 400 URL resolves to a private/internal IP address: 127.0.0.1
```

A CLI write is not visible to a running server until it restarts — `openproxy provider add`
and `openproxy combo create` write the store, but the running process serves its own
snapshot. If a change appears to be ignored, `openproxy server stop` then `server start
--detach --no-open` and retry.

## Common failure modes

| Symptom | Cause and fix |
|---|---|
| `404` on the fetch | You used `/v1/fetch`. Use `/v1/web/fetch` — see §1. |
| `405` on the fetch | Wrong method. `POST` and `OPTIONS` only. |
| `400 No credentials for provider: X` | No active connection whose `provider` field is exactly `X`. Short aliases are resolved on the request side only, so the stored row needs the canonical id. |
| `400 Invalid media_type` from `media providers add` | `--kind webFetch` is not whitelisted. Use `openproxy provider add`, or `--kind search` for dashboard visibility. |
| CLI prints an empty line | Non-robot output reads `content` as a string. Use `--robot` and `.data.content.text` — see §3. |
| `401` on every call | `require_login` defaults to true. Send a bearer. |
| `400 URL resolves to a private/internal IP address` | SSRF guard, including after DNS. Not configurable. |
| `504 Request timed out` | 30 s hard cap on the upstream call. |
| `content.text` empty but status 200 | The upstream returned a shape the normalizer did not recognise for that provider. |
| Combo change has no effect | Restart the server so it reloads the combo. |

## Not this skill

- **Web *search*** (query → links) is a different endpoint: `POST /v1/search`, and
  `openproxy media search --provider X --query "…"`. Its body needs `model` set to
  `<provider>/search` plus `query`; the provider alone is not enough. `/v1/web/fetch` needs
  `provider` plus `url` and takes no `query`.
- **Chat with a web-enabled provider** goes through `/v1/chat/completions`.
- General install, provider, combo, and CLI-tool wiring: `.agents/skills/openproxy/SKILL.md`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.