{"record":{"id":"a68f5a2d0f830383","repo":"koala73/worldmonitor","slug":"redirect-to-disallowed-domain","errorCode":null,"errorMessage":"Redirect to disallowed domain","messagePattern":"Redirect to disallowed domain","errorType":"http","errorClass":"RssProxyPolicyError","httpStatus":403,"severity":"error","filePath":"api/rss-proxy.js","lineNumber":237,"sourceCode":"    return new URL(feedUrl).hostname === 'news.google.com';\n  } catch {\n    return false;\n  }\n}\n\nfunction assertHttpProtocol(url, message = 'URL protocol not allowed', status = 400) {\n  if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n    throw new RssProxyPolicyError(message, status);\n  }\n}\n\nfunction assertAllowedRedirect(url) {\n  assertHttpProtocol(url, 'Redirect protocol not allowed', 403);\n  // Apply the same www-normalization as the initial domain check so that\n  // canonical redirects (e.g. apex -> www) are not incorrectly rejected when\n  // only one form is in the allowlist.\n  if (!isAllowedDomain(url.hostname)) {\n    throw new RssProxyPolicyError('Redirect to disallowed domain');\n  }\n}\n\nexport default async function handler(req, ctx) {\n  const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');\n\n  if (isDisallowedOrigin(req)) {\n    return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders);\n  }\n\n  // Handle CORS preflight\n  if (req.method === 'OPTIONS') {\n    return new Response(null, { status: 204, headers: corsHeaders });\n  }\n  if (req.method !== 'GET') {\n    return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders);\n  }\n","sourceCodeStart":219,"sourceCodeEnd":255,"githubUrl":"https://github.com/koala73/worldmonitor/blob/e586b8b4b80f595aa7ece295eec10d76f2921240/api/rss-proxy.js#L219-L255","documentation":"The proxy follows redirects manually (redirect: 'manual') and re-applies the domain allowlist to every Location header, with the same www-prefix normalization as the initial check. This RssProxyPolicyError (403) means the redirect chain left the allowlisted feed domains — the guard that prevents the proxy from being used as an open relay / SSRF vector.","triggerScenarios":"An allowlisted host 301/302s to a different domain (redirect service, publisher migration, unrelated CDN); region-based mirrors chosen per request; the allowlist containing only one of the domains the chain passes through.","commonSituations":"A publisher moves its feed and the legacy URL redirects forever; chains that combine http→https and host changes; adding feeds by their old feedburner-style URLs.","solutions":["Trace the chain: curl -sIL '<feed-url>' | grep -i '^location'","Request the final destination URL directly through the proxy instead of the redirecting one","If the target is a domain you intentionally serve, add it to the allowlist in api/_rss-allowed-domain-match.js (shared with the CI validator so both stay in sync)","If the chain looks unstable or suspicious, drop the feed from the client list"],"exampleFix":"# before\n$ curl -sIL https://feeds.example.com/rss | grep -i '^location'\nLocation: https://cdn.example-cdn.com/feed.xml   # not allowlisted -> 403 Redirect to disallowed domain\n\n# after — add the redirect target to the shared allowlist in api/_rss-allowed-domain-match.js\n# (e.g. 'cdn.example-cdn.com'), or call the proxy with the final URL:\nGET /api/rss-proxy?url=https://cdn.example-cdn.com/feed.xml","handlingStrategy":"validation","validationCode":"// Resolve the redirect chain client-side and confirm every hop stays allowlisted\nasync function finalAllowedUrl(feedUrl, isAllowed) {\n  let u = new URL(feedUrl);\n  for (let i = 0; i < 3; i++) {\n    const res = await fetch(u, { redirect: 'manual' });\n    const loc = res.headers.get('location');\n    if (!loc) return u.href;\n    u = new URL(loc, u);\n    if (!isAllowed(u.hostname)) throw new Error(`Chain leaves allowlist at ${u.hostname}`);\n  }\n  throw new Error('Too many redirects');\n}","typeGuard":null,"tryCatchPattern":"try {\n  const xml = await fetchFeedViaProxy(feedUrl);\n} catch (err) {\n  if (/Redirect to disallowed domain/.test(err.message)) {\n    // policy stop: resolve the chain, then either pin the final URL or extend the allowlist\n    return pinFinalUrl(feedUrl);\n  }\n  throw err;\n}","preventionTips":["Store the final redirect target in feed configs instead of legacy redirecting URLs","Extend the shared allowlist (api/_rss-allowed-domain-match.js) whenever adding a feed whose chain crosses domains","Periodically re-run the CI feed validator to catch chains that drift off the allowlist"],"tags":["ssrf","redirect","allowlist","rss-proxy","security"],"backgroundTag":"ssrf-redirect-blocked","analyzedSha":"e586b8b4b80f595aa7ece295eec10d76f2921240","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}