AJAX RSS Reader
Proxying RSS through PHP, caching, and working around cross-origin limits.
Key PointsBrowsers cannot fetch cross-origin RSS directly — proxy and cache it in PHP.
Why proxy
fetch("https://other-site.com/rss") is blocked by the same-origin policy unless the server sends Access-Control-Allow-Origin. RSS sites almost never do.
So your own server fetches it and the browser calls a same-origin PHP endpoint. Same-origin rules do not apply between servers.
The proxy endpoint
PHP
<?php
// /api/rss.php?feed=runoob
header("Content-Type: application/json; charset=utf-8");
$feeds = [
"runoob" => "https://www.runoob.com/feed",
"php" => "https://www.php.net/feed.atom",
];
$key = $_GET["feed"] ?? "";
// 白名单:绝不能让用户指定任意 URL,否则就是 SSRF
if (!isset($feeds[$key])) {
http_response_code(400);
echo json_encode(["ok" => false, "msg" => "未知订阅源"]);
exit;
}
$cacheFile = sys_get_temp_dir() . "/rss_" . $key . ".json";
$ttl = 600;
if (is_file($cacheFile) && time() - filemtime($cacheFile) < $ttl) {
echo file_get_contents($cacheFile);
exit;
}
$ctx = stream_context_create([
"http" => [
"timeout" => 5,
"header" => "User-Agent: MyReader/1.0\r\n",
],
]);
$raw = @file_get_contents($feeds[$key], false, $ctx);
if ($raw === false) {
http_response_code(502);
echo json_encode(["ok" => false, "msg" => "抓取失败"]);
exit;
}
$xml = @simplexml_load_string($raw);
if ($xml === false) {
http_response_code(502);
echo json_encode(["ok" => false, "msg" => "不是合法 XML"]);
exit;
}
$items = [];
foreach ($xml->channel->item ?? [] as $it) {
$items[] = [
"title" => (string)$it->title,
"link" => (string)$it->link,
"date" => (string)$it->pubDate,
];
}
$out = json_encode(["ok" => true, "items" => $items], JSON_UNESCAPED_UNICODE);
file_put_contents($cacheFile, $out);
echo $out;The response shape:
Output
{"ok":true,"items":[{"title":"PHP 8.3 发布","link":"https://www.php.net/archive/2023.php","date":"Wed, 15 Nov 2023 06:13:20 +0000"}]}The
$feeds allowlist is mandatory. Allowing arbitrary URLs enables SSRF — your server reaching cloud metadata at 169.254.169.254 or internal Redis. One of the most dangerous cloud vulnerabilities.Rendering
JavaScript
async function loadFeed(key) {
const res = await fetch(`/api/rss.php?feed=${encodeURIComponent(key)}`);
const data = await res.json();
const box = document.querySelector("#feed");
if (!data.ok) {
box.innerHTML = `<p class="err">${escapeHtml(data.msg)}</p>`;
return;
}
box.innerHTML = data.items.map(it => `
<article>
<h3><a href="${escapeAttr(it.link)}" target="_blank" rel="noopener">${escapeHtml(it.title)}</a></h3>
<time>${escapeHtml(it.date)}</time>
</article>
`).join("");
}rel="noopener" is required, or the opened page can navigate yours via window.opener — reverse tabnabbing.Caching
| Approach | Pros | Cons |
|---|---|---|
| 文件缓存 | 零依赖,实现简单 | 多机不共享 |
| Redis | 多机共享,TTL 精确 | 需额外服务 |
| 数据库 | 可审计 | 比文件还重 |
| 不缓存 | 实时 | 慢,且容易被封 IP |
RSS updates hourly at most, so a 10-minute cache suffices. Without caching every page load hits the source and gets rate-limited fast.
A safer approach
- Pre-fetch on a cron —— pull every 10 minutes into the database; the API only reads
- Check Content-Type —— accept only RSS/Atom MIME types
- Cap the response size —— read at most N KB
- Block redirects to private IPs —— handle 3xx and validate the target
file_get_contents follows redirects, so a public URL can 302 to http://127.0.0.1/admin and bypass the allowlist. Disable redirects or re-validate each hop.为什么要代理
fetch("https://other-site.com/rss") 会被同源策略拦下,除非对方返回了 Access-Control-Allow-Origin。RSS 站点几乎都不会。
所以让自己的服务器去抓,浏览器只请求同域的 PHP 接口。PHP 之间没有同源限制。
代理接口
PHP
<?php
// /api/rss.php?feed=runoob
header("Content-Type: application/json; charset=utf-8");
$feeds = [
"runoob" => "https://www.runoob.com/feed",
"php" => "https://www.php.net/feed.atom",
];
$key = $_GET["feed"] ?? "";
// 白名单:绝不能让用户指定任意 URL,否则就是 SSRF
if (!isset($feeds[$key])) {
http_response_code(400);
echo json_encode(["ok" => false, "msg" => "未知订阅源"]);
exit;
}
$cacheFile = sys_get_temp_dir() . "/rss_" . $key . ".json";
$ttl = 600;
if (is_file($cacheFile) && time() - filemtime($cacheFile) < $ttl) {
echo file_get_contents($cacheFile);
exit;
}
$ctx = stream_context_create([
"http" => [
"timeout" => 5,
"header" => "User-Agent: MyReader/1.0\r\n",
],
]);
$raw = @file_get_contents($feeds[$key], false, $ctx);
if ($raw === false) {
http_response_code(502);
echo json_encode(["ok" => false, "msg" => "抓取失败"]);
exit;
}
$xml = @simplexml_load_string($raw);
if ($xml === false) {
http_response_code(502);
echo json_encode(["ok" => false, "msg" => "不是合法 XML"]);
exit;
}
$items = [];
foreach ($xml->channel->item ?? [] as $it) {
$items[] = [
"title" => (string)$it->title,
"link" => (string)$it->link,
"date" => (string)$it->pubDate,
];
}
$out = json_encode(["ok" => true, "items" => $items], JSON_UNESCAPED_UNICODE);
file_put_contents($cacheFile, $out);
echo $out;返回结构:
Output
{"ok":true,"items":[{"title":"PHP 8.3 发布","link":"https://www.php.net/archive/2023.php","date":"Wed, 15 Nov 2023 06:13:20 +0000"}]}$feeds 白名单是必须的。如果允许 ?feed=<任意 URL>,攻击者就能让你的服务器去访问内网地址(http://169.254.169.254/ 云元数据、http://127.0.0.1:6379/),这叫 SSRF,是云环境最危险的漏洞之一。前端渲染
JavaScript
async function loadFeed(key) {
const res = await fetch(`/api/rss.php?feed=${encodeURIComponent(key)}`);
const data = await res.json();
const box = document.querySelector("#feed");
if (!data.ok) {
box.innerHTML = `<p class="err">${escapeHtml(data.msg)}</p>`;
return;
}
box.innerHTML = data.items.map(it => `
<article>
<h3><a href="${escapeAttr(it.link)}" target="_blank" rel="noopener">${escapeHtml(it.title)}</a></h3>
<time>${escapeHtml(it.date)}</time>
</article>
`).join("");
}rel="noopener" 不能省。不加的话,被打开的页面能通过 window.opener 把你的页面跳转到钓鱼站——叫 reverse tabnabbing。缓存策略
| 方式 | 优点 | 缺点 |
|---|---|---|
| 文件缓存 | 零依赖,实现简单 | 多机不共享 |
| Redis | 多机共享,TTL 精确 | 需额外服务 |
| 数据库 | 可审计 | 比文件还重 |
| 不缓存 | 实时 | 慢,且容易被封 IP |
RSS 更新频率通常是小时级,缓存 10 分钟完全够。不缓存等于每次页面加载都去打对方服务器,很快就会被限流。
更稳妥的做法
- 用定时任务预抓 —— cron 每 10 分钟抓一次写库,接口只读库
- 校验 Content-Type —— 只接受
application/rss+xml/application/atom+xml - 限制响应大小 ——
stream_get_meta_data或读前 N KB - 禁止重定向到内网 —— 手动处理 3xx 并校验目标 IP
file_get_contents 抓远程 URL 会跟随重定向。攻击者用一个公网地址 302 跳到 http://127.0.0.1/admin 就能绕过白名单。**要么禁重定向(max_redirects => 0),要么每次跳转都重新校验目标。**