Conversation
…ts through pinnedFetch A `url` credential field (Gitea instance URL, OpenAI-compatible base URL) is checked against the @repo/net rules when stored: https only, no private or local host unless SSRF_ALLOWED_HOSTS names it, no userinfo, query or fragment. The stored value is origin plus path. Every outbound client under packages/agent-tools sends through pinnedFetch, which vets and pins the host on every call and returns a redirect instead of following it with the token. The agent runtime vets the model credential's base URL again before handing it to the provider SDK, and an update that moves a url field must carry the secret again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
net refactor and pinnedFetch are fine. Blocker is resolveModel: vetting baseUrl on every run breaks self-hosted local models (http://ollama:11434), and SSRF_ALLOWED_HOSTS doesn't help — https is checked before the host. Existing credentials break and can't be re-saved. Allow http for hosts named in SSRF_ALLOWED_HOSTS, then it's good. |
The scheme was checked before the hostname was read, so naming a host could not admit http to it and a self-hosted model server on http://ollama:11434 had no way to be configured at all. The host and the allowlist are now resolved first, and http reaches a named host. Every other scheme is still refused, the resolved address is still pinned, and a host nobody named is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Done, pushed. Three things worth flagging before you merge. This is not confined to the model path. The ordering is upstream, not something this PR introduced, and It reverses one of your own tests. One break is left, and it is a config change on upgrade. An instance that already stores If that last one bothers you, there is a cheaper way out than the allowlist: drop the vetting in Verification: |
…-validation # Conflicts: # packages/net/src/index.ts
What
A
urlcredential field is validated when stored, and the outbound tool clients in@repo/agent-toolssend through the@repo/netSSRF guard.coerceConfiggivestype: 'url'its own branch: the value must parse, use https, and not name a private or local host unlessSSRF_ALLOWED_HOSTSlists it (same rules and same env var as repository connections and webhooks); userinfo, query string and fragment are refused. The stored value is the origin plus path with no trailing slash — the path is kept because an OpenAI-compatible endpoint carries/v1and a Gitea can be served under a sub-path. Both url fields in the catalog (GiteabaseUrlin@repo/agent-tools, LLM providerbaseUrlinintegrations/catalog.ts) go through it, so a model credential is validated at save time too.@repo/netexportscheckHttpUrl, the synchronous subset ofassertPublicHttpUrl(no DNS lookup), whichvetnow also uses internally; the behaviour ofassertPublicHttpUrlandpinnedFetchis unchanged.@repo/agent-toolsdepends on@repo/net, and every client in the package (Gitea, Notion, Firecrawl, Threads, Instagram, Jina, Telegram) callspinnedFetchinstead of globalfetch: the host is resolved and pinned per call and a 3xx is returned rather than followed, so a token never leaves the vetted origin. The Gitea client turns a 3xx into a message telling the operator to set the URL Gitea answers on.resolveModel) runs the stored model base URL throughassertPublicHttpUrlbefore passing it to the provider SDK, so a credential stored before this change, or a host whose address changed since, is caught at run time.PATCH /teams/:teamId/integrations/:credentialId: when the patch changes a url field, every secret field of the schema must be in the patch; otherwise 400Changing <url label> requires entering <secret label> again. Resending the same url (what the edit form does) is not a change.Why
A credential's url field reached the runtime as a plain string, and the Gitea client fetched it with global
fetch, so a stored URL could address an internal or link-local host and the token would be sent there, redirects included. The model providerbaseUrlhad the same gap on the run path, and an edit that only changedbaseUrlforwarded the stored secret to the new address. Closes the SSRF finding on url-type integration credentials.How to test
http://URL, a private address (https://10.0.0.5),https://localhost:3000or a URL withuser:pw@is rejected with a 400 naming the field;https://git.example.com/is stored ashttps://git.example.com.https://llm.example.com/v1/: stored ashttps://llm.example.com/v1;http://…is rejected.SSRF_ALLOWED_HOSTS=<your private gitea host>on the api: that host (https only) is accepted; any other private host is still refused.cd packages/agent-tools && bun test,cd packages/net && bun test,cd apps/api && bun test --env-file=../../.env.test src/modules/agents/integrations.Checklist
bun run typecheckpassesbun run lintandbun run format:checkpassbun run db:generateand committed.env.example(no new variable; the existingSSRF_ALLOWED_HOSTSnote now lists integration credentials as a path that reads it)README.mdor the relevantAGENTS.md) —packages/agent-tools/README.md🤖 Generated with Claude Code