Files
silly-rss/php/common.php
T
paraandClaude Sonnet 5 a46e9c64a2 Harden PHP feeds: block direct cache access, cap curl redirects
Security review found no exploitable issue — none of the 3 endpoints
accept user input, so there's no injection surface (upstream URLs are
hardcoded constants, output is XML-escaped). These are defense-in-depth
only: deny direct HTTP access to cache/, and cap CURLOPT_MAXREDIRS to
avoid a hung request on a misbehaving upstream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:19:36 +02:00

139 lines
4.7 KiB
PHP

<?php
/**
* Fonctions communes aux 3 flux RSS (actualites.php, agenda.php, facebook.php).
*
* Le site www.silly.be est derriere Cloudflare : son WAF bloque les
* requetes dont l'empreinte TLS/HTTP2 n'est pas "navigateur" (urllib
* Python passe, par ex., mais renvoie un corps vide). L'extension
* curl de PHP (libcurl) reproduit la meme empreinte que le binaire
* curl en ligne de commande, donc ca passe nativement.
*
* Autre contrainte observee sur l'API : b_size doit valoir exactement
* 20 (toute autre valeur renvoie un corps vide) ; la pagination se
* fait donc via b_start par pas de 20.
*/
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const CACHE_DIR = __DIR__ . '/cache';
const CACHE_TTL_SECONDS = 900; // 15 minutes
/**
* Recupere une URL via curl (necessaire pour passer le WAF Cloudflare
* de silly.be) et renvoie le corps de la reponse en texte.
*/
function http_get(string $url, array $extraHeaders = []): string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => USER_AGENT,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $extraHeaders),
CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno !== 0) {
throw new RuntimeException("curl error fetching $url: $error");
}
if ($status >= 400) {
throw new RuntimeException("HTTP $status fetching $url");
}
return $body;
}
/**
* Sert un contenu XML depuis un cache fichier, en le regenerant via
* $builder() si le cache est absent ou perime. Evite de solliciter
* silly.be / fetchrss.com a chaque poll de chaque abonne FreshRSS.
*/
function serve_cached_feed(string $cacheKey, callable $builder): void
{
header('Content-Type: application/rss+xml; charset=utf-8');
if (!is_dir(CACHE_DIR)) {
mkdir(CACHE_DIR, 0775, true);
}
$cacheFile = CACHE_DIR . '/' . $cacheKey . '.xml';
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL_SECONDS) {
readfile($cacheFile);
return;
}
try {
$xml = $builder();
file_put_contents($cacheFile, $xml);
echo $xml;
} catch (Throwable $e) {
// En cas d'erreur (site source indisponible, etc.), on sert le
// cache perime plutot que de casser le flux chez les abonnes.
if (is_file($cacheFile)) {
readfile($cacheFile);
return;
}
http_response_code(502);
header('Content-Type: text/plain; charset=utf-8');
echo "Erreur de generation du flux : " . $e->getMessage();
}
}
function strip_html(?string $text): string
{
if (!$text) {
return '';
}
$text = preg_replace('/<[^>]+>/', ' ', $text);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/\s+/', ' ', $text));
}
function rfc822(?string $isoDate): string
{
try {
$dt = $isoDate ? new DateTime($isoDate) : new DateTime('now', new DateTimeZone('UTC'));
} catch (Exception $e) {
$dt = new DateTime('now', new DateTimeZone('UTC'));
}
return $dt->format(DateTime::RSS);
}
function xml_escape(string $text): string
{
return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
/**
* Construit un flux RSS 2.0 complet a partir d'une liste d'items deja
* formates (chacun un tableau title/link/guid/pubDate/description).
*/
function build_rss(string $title, string $link, string $description, array $items): string
{
$now = rfc822(null);
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rss version="2.0">' . "\n <channel>\n";
$xml .= ' <title>' . xml_escape($title) . "</title>\n";
$xml .= ' <link>' . xml_escape($link) . "</link>\n";
$xml .= ' <description>' . xml_escape($description) . "</description>\n";
$xml .= " <language>fr-be</language>\n";
$xml .= " <lastBuildDate>$now</lastBuildDate>\n";
foreach ($items as $item) {
$xml .= " <item>\n";
$xml .= ' <title>' . xml_escape($item['title']) . "</title>\n";
$xml .= ' <link>' . xml_escape($item['link']) . "</link>\n";
$xml .= ' <guid isPermaLink="false">' . xml_escape($item['guid']) . "</guid>\n";
$xml .= ' <pubDate>' . rfc822($item['pubDate'] ?? null) . "</pubDate>\n";
$xml .= ' <description>' . xml_escape($item['description']) . "</description>\n";
$xml .= " </item>\n";
}
$xml .= " </channel>\n</rss>\n";
return $xml;
}