You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ingest_sources.rs builds a fresh reqwest::Client in six places — sync_urls, sync_rss, sync_github_issues, sync_jira_issues, sync_custom, sync_webdav — and each one sets a timeout and a user agent and nothing else. Object storage and Notion reach the network through their own clients on the same terms.
None of them asks where the request is going.
What that leaves open
The destination is never checked. A source URL of http://169.254.169.254/latest/meta-data/ is fetched, stored as a document, and indexed. So is http://127.0.0.1:9200/, and anything else the container can route to.
Redirects are followed blind.reqwest follows up to ten by default, so a host that passes any future check on its first hop can still hand back a Location pointing inside the network.
The body is unbounded.fetch_page ends in resp.bytes().await? with no cap; a large response is read into memory in one piece.
Who chose the URL matters
There are two provenances here and they do not want the same policy:
The operator typed it. A url, webdav, jira or custom source has a host that an editor entered on purpose. sync_custom even has an explicit loopback branch that disables the proxy so a local integration works — a blanket ban on private addresses would break a case that is currently deliberate.
Content chose it. The links inside an RSS entry are written by whoever wrote the feed. Add guarded full-content hydration for new RSS items #326 fetches those, and that is the case where "an editor picked this host" degrades to "a feed said so".
So the guard is a policy the caller passes, not a global switch: content-derived destinations are public-only, operator-typed ones stay a deployment decision.
What to build
One client factory, used by every path, taking that policy. #326 already contains the implementation worth starting from — resolve the host, require every returned address to be public, pin them with resolve_to_addrs so a rebind cannot move the target after the check, walk redirects by hand and re-validate each hop, refuse an https→http downgrade, disable the proxy and content encodings, allowlist the content type, and cap both bytes and total time.
Two things to fix while it moves: is_public_ip there treats all of 192.0.0.0/16 as reserved when only two /24s are (which also makes the 192.0.2.x line below it unreachable), and 401/403 are counted retryable, so a host that has firmly refused is asked five times.
Scope
Utopia is self-hosted and creating a source needs editor rights in a base, so this is not a "anyone on the internet reads your metadata service" hole. It is the ordinary shape of SSRF in a product that fetches URLs on the server: the blast radius is whatever the deployment can reach, and the RSS case lowers the bar for who picks the address.
ingest_sources.rsbuilds a freshreqwest::Clientin six places —sync_urls,sync_rss,sync_github_issues,sync_jira_issues,sync_custom,sync_webdav— and each one sets a timeout and a user agent and nothing else. Object storage and Notion reach the network through their own clients on the same terms.None of them asks where the request is going.
What that leaves open
http://169.254.169.254/latest/meta-data/is fetched, stored as a document, and indexed. So ishttp://127.0.0.1:9200/, and anything else the container can route to.reqwestfollows up to ten by default, so a host that passes any future check on its first hop can still hand back aLocationpointing inside the network.fetch_pageends inresp.bytes().await?with no cap; a large response is read into memory in one piece.Who chose the URL matters
There are two provenances here and they do not want the same policy:
url,webdav,jiraorcustomsource has a host that an editor entered on purpose.sync_customeven has an explicit loopback branch that disables the proxy so a local integration works — a blanket ban on private addresses would break a case that is currently deliberate.So the guard is a policy the caller passes, not a global switch: content-derived destinations are public-only, operator-typed ones stay a deployment decision.
What to build
One client factory, used by every path, taking that policy. #326 already contains the implementation worth starting from — resolve the host, require every returned address to be public, pin them with
resolve_to_addrsso a rebind cannot move the target after the check, walk redirects by hand and re-validate each hop, refuse an https→http downgrade, disable the proxy and content encodings, allowlist the content type, and cap both bytes and total time.Two things to fix while it moves:
is_public_ipthere treats all of192.0.0.0/16as reserved when only two/24s are (which also makes the192.0.2.xline below it unreachable), and 401/403 are counted retryable, so a host that has firmly refused is asked five times.Scope
Utopia is self-hosted and creating a source needs editor rights in a base, so this is not a "anyone on the internet reads your metadata service" hole. It is the ordinary shape of SSRF in a product that fetches URLs on the server: the blast radius is whatever the deployment can reach, and the RSS case lowers the bar for who picks the address.