Skip to content

Commit c35ccb7

Browse files
ben-xoclaude
andcommitted
Fix six audit findings; bring tests up to PHP 8.5
* itunes:block now only emits for "yes"/"Yes" per Apple's spec; previously any non-empty value (including the example "no" in dir2cast.ini) was emitted, which blocks the feed from directories. * Per-MP3-dir dir2cast.ini's mtime now invalidates the cache via a new LOCAL_INI_FILE constant tracked alongside INI_FILE; editing a per-podcast ini no longer silently serves stale cached output. * Caching_getID3_Podcast_Helper::addNamespaceTo now delegates to the wrapped helper's addNamespaceTo (was calling appendToChannel). * ?force=... password compare now uses hash_equals() with explicit string casts: avoids PHP's numeric-string coercion in == ("01" == "1", "0e1234" == "0e5678") and timing leaks. * HTTP Last-modified header now uses RFC 7231 IMF-fixdate (" GMT") instead of RFC 2822 (" +0000"); some intermediaries rejected the non-conforming form. RSS <lastBuildDate> still uses RFC 2822. * HTTP Last-modified falls back to the cache file's mtime when the cached XML lacks an extractable <lastBuildDate>. Each fix is preceded by a test that fails on the buggy code. Also fixes the test suite for newer PHPs and expands CI coverage: * bootstrap.php: handle E_STRICT removal in PHP 8.5. * bootstrap.php: fake_getopt rewritten to pass user argv as real CLI args; PHP 8.4+ ignores $GLOBALS['argv'] for getopt(). * .github/workflows/testing.yml: matrix now covers 8.3, 8.4, 8.5; macos-11 -> macos-latest (older runner retired). Version bumped to 1.39. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0e6a394 commit c35ccb7

12 files changed

Lines changed: 384 additions & 26 deletions

.github/workflows/testing.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ jobs:
44
build:
55
strategy:
66
matrix:
7-
operating-system: [ubuntu-latest, macos-11]
8-
php-versions: ['7.3', '7.4', '8.0', '8.1', '8.2']
7+
operating-system: [ubuntu-latest, macos-latest]
8+
php-versions: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
99
runs-on: ${{ matrix.operating-system }}
1010
steps:
1111
- name: Setup PHP and extensions

CHANGELOG.txt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
Changelog
22
=========
33

4+
1.39 2026-04-28 * Fix: ITUNES_BLOCK now only emits <itunes:block> for the
5+
documented values "yes"/"Yes". Previously any non-empty
6+
value (including the example "no" from dir2cast.ini)
7+
would emit the tag, contradicting the docs and the spec.
8+
* Fix: the per-MP3-dir dir2cast.ini's mtime now invalidates
9+
the cache. Editing a per-podcast ini file no longer
10+
silently serves stale cached output.
11+
* Fix: HTTP Last-Modified header now uses RFC 7231 IMF-fixdate
12+
("... GMT") instead of RFC 2822 ("... +0000"). The channel's
13+
<lastBuildDate> still uses RFC 2822 as required by RSS.
14+
* Fix: HTTP Last-Modified falls back to the cache file's
15+
mtime when the cached feed has no extractable
16+
<lastBuildDate> (avoids emitting an empty header).
17+
* Fix: Caching_getID3_Podcast_Helper::addNamespaceTo now
18+
delegates to the wrapped helper's addNamespaceTo (was
19+
incorrectly calling appendToChannel).
20+
* Security: ?force=... password compare now uses
21+
hash_equals() with explicit string casts, avoiding
22+
PHP's numeric-string coercion in == and timing leaks.
23+
* Tests now run on PHP 8.4 and 8.5 (E_STRICT removal,
24+
getopt() argv-injection change). CI matrix expanded
25+
to cover 8.3, 8.4 and 8.5.
26+
427
1.38 2023-01-05 * Add <itunes:type> defaulting to "episodic". The ITUNES_TYPE
528
option in dir2cast.ini will let you set the feed to
629
<itunes:type>serial<itunes:type> set, and then,

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[![Testing dir2cast](https://github.com/ben-xo/dir2cast/actions/workflows/testing.yml/badge.svg)](https://github.com/ben-xo/dir2cast/actions/workflows/testing.yml)
22

33

4-
dir2cast by Ben XO v1.38 (2023-01-05)
4+
dir2cast by Ben XO v1.39 (2026-04-28)
55
================================================================================
66

77
https://github.com/ben-xo/dir2cast/

dir2cast.ini

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,12 @@
107107

108108
; Whether or not to include the podcast in the iTunes podcast directory.
109109
; See https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
110-
; Valid values are "Yes" or "yes". Any other value will be ignored.
110+
; Valid values are "Yes" or "yes". Any other value (including the default of
111+
; unset, or e.g. "no") will be ignored and the tag will be omitted from the feed.
111112
;
112-
; Only set this if you want the feed to be private and completely excluded from
113+
; Only set this if you want the feed to be private and completely excluded from
113114
; any podcast directory scanning.
114-
;ITUNES_BLOCK = "no"
115+
;ITUNES_BLOCK = "yes"
115116

116117

117118
; *** INFORMATION ABOUT YOUR PODCAST - the following can be set using text files ***

dir2cast.php

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
/* DEFAULTS *********************************************/
5757

5858
// error handler needs these, so let's set them now.
59-
define('VERSION', '1.38');
59+
define('VERSION', '1.39');
6060
define('DIR2CAST_HOMEPAGE', 'https://github.com/ben-xo/dir2cast/');
6161
define('GENERATOR', 'dir2cast ' . VERSION . ' by Ben XO (' . DIR2CAST_HOMEPAGE . ')');
6262

@@ -234,7 +234,7 @@ public function appendToChannel(DOMElement $d, DOMDocument $doc) {
234234
}
235235

236236
public function addNamespaceTo(DOMElement $d, DOMDocument $doc) {
237-
return $this->wrapped_helper->appendToChannel($d, $doc);
237+
return $this->wrapped_helper->addNamespaceTo($d, $doc);
238238
}
239239

240240
public function appendToItem(DOMElement $d, DOMDocument $doc, RSS_Item $item)
@@ -390,7 +390,9 @@ public function appendToChannel(DOMElement $channel, DOMDocument $doc)
390390
->appendChild( new DOMText( $this->explicit ) );
391391
}
392392

393-
if(!empty($this->block))
393+
// Per Apple's spec, <itunes:block> is only meaningful when set to "Yes"/"yes";
394+
// any other value must be ignored, so omit the tag entirely.
395+
if(!empty($this->block) && strtolower($this->block) === 'yes')
394396
{
395397
$channel->appendChild( $doc->createElement('itunes:block') )
396398
->appendChild( new DOMText( $this->block ) );
@@ -1072,11 +1074,32 @@ public function addHelper(Podcast_Helper $helper)
10721074

10731075
public function http_headers($content_length)
10741076
{
1075-
// The correct content type is application/rss+xml; however, the de-facto standard is now text/xml.
1077+
// The correct content type is application/rss+xml; however, the de-facto standard is now text/xml.
10761078
// See https://stackoverflow.com/questions/595616/what-is-the-correct-mime-type-to-use-for-an-rss-feed
10771079
header('Content-type: text/xml; charset=UTF-8');
10781080
header('Content-length: ' . $content_length);
1079-
header('Last-modified: ' . $this->getLastBuildDate());
1081+
$http_last_modified = $this->httpLastModified();
1082+
if($http_last_modified !== null) {
1083+
header('Last-modified: ' . $http_last_modified);
1084+
}
1085+
}
1086+
1087+
/**
1088+
* Returns the last-modified timestamp formatted as an HTTP-date (IMF-fixdate, RFC 7231)
1089+
* suitable for the HTTP `Last-Modified` header. The channel's <lastBuildDate> uses
1090+
* RFC 2822 format (date('r')) which is correct for RSS but not for HTTP headers.
1091+
*/
1092+
public function httpLastModified()
1093+
{
1094+
$lbd = $this->getLastBuildDate();
1095+
if(!$lbd) {
1096+
return null;
1097+
}
1098+
$ts = strtotime((string)$lbd);
1099+
if($ts === false) {
1100+
return null;
1101+
}
1102+
return gmdate('D, d M Y H:i:s', $ts) . ' GMT';
10801103
}
10811104

10821105
/**
@@ -1526,6 +1549,12 @@ public function generate()
15261549
{
15271550
$this->setLastBuildDate($matches[1]);
15281551
}
1552+
else
1553+
{
1554+
// Fall back to the cache file's mtime so HTTP Last-Modified is still
1555+
// populated (e.g. if the cached XML predates this format or is corrupt).
1556+
$this->setLastBuildDate(date('r', filemtime($this->temp_file)));
1557+
}
15291558
}
15301559
else
15311560
{
@@ -1864,11 +1893,16 @@ public static function bootstrap(array $SERVER, array $GET, array $argv)
18641893
public static function defaults(array $SERVER)
18651894
{
18661895
// if an MP3_DIR specific config file exists, load it now, as long as it's not the same file as the global one!
1867-
if(
1868-
file_exists( MP3_DIR() . 'dir2cast.ini' ) and
1869-
realpath(DIR2CAST_BASE() . 'dir2cast.ini') != realpath( MP3_DIR() . 'dir2cast.ini' )
1896+
if(
1897+
file_exists( MP3_DIR() . 'dir2cast.ini' ) and
1898+
realpath(DIR2CAST_BASE() . 'dir2cast.ini') != realpath( MP3_DIR() . 'dir2cast.ini' )
18701899
) {
1871-
self::load_from_ini( MP3_DIR() . 'dir2cast.ini' );
1900+
$local_ini = MP3_DIR() . 'dir2cast.ini';
1901+
self::load_from_ini( $local_ini );
1902+
// Track this so the dispatcher can use its mtime for cache invalidation;
1903+
// INI_FILE alone only covers the install-wide ini.
1904+
if(!defined('LOCAL_INI_FILE'))
1905+
define('LOCAL_INI_FILE', $local_ini);
18721906
}
18731907

18741908
self::finalize();
@@ -2100,8 +2134,15 @@ public function __construct(Locking_Cached_Dir_Podcast $podcast)
21002134

21012135
public function uncache_if_forced($force_password, $get)
21022136
{
2103-
if( strlen($force_password) && isset($get['force']) && $force_password == $get['force'] )
2104-
{
2137+
// Cast both sides to string and use hash_equals to avoid two issues:
2138+
// (1) PHP's `==` does numeric coercion of numeric-looking strings (so e.g.
2139+
// "01" loosely equals "1"); (2) `==`/`===` are not constant-time, so
2140+
// they leak password length / prefix via timing.
2141+
if(
2142+
strlen((string)$force_password) &&
2143+
isset($get['force']) &&
2144+
hash_equals((string)$force_password, (string)$get['force'])
2145+
) {
21052146
$this->podcast->uncache();
21062147
}
21072148
}
@@ -2121,6 +2162,8 @@ public function update_mtime_if_dir2cast_or_settings_modified()
21212162
$this->podcast->updateMaxMtime(filemtime(__FILE__), __FILE__);
21222163
if(defined('INI_FILE'))
21232164
$this->podcast->updateMaxMtime(filemtime(INI_FILE), INI_FILE);
2165+
if(defined('LOCAL_INI_FILE'))
2166+
$this->podcast->updateMaxMtime(filemtime(LOCAL_INI_FILE), LOCAL_INI_FILE);
21242167
}
21252168

21262169
public function update_mtime_if_metadata_files_modified()

test/Cached_Dir_PodcastTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,43 @@ public function test_lastBuildDate_is_valid_whether_served_from_cache_or_not()
168168
$this->assertEquals($lastBuildDate, $mp3->getLastBuildDate());
169169
}
170170

171+
public function test_lastBuildDate_falls_back_to_cache_filemtime_when_unparseable()
172+
{
173+
// If the cached XML doesn't contain an extractable <lastBuildDate> (corrupted,
174+
// truncated, manually edited, or written by an older version that didn't
175+
// emit one), the served HTTP `Last-Modified` header would be empty. In that
176+
// case we should fall back to the cache file's mtime so the header is still sane.
177+
$this->createTestItems();
178+
age_dir_by('.', 3600);
179+
180+
$mp = $this->newPodcast();
181+
$mp->generate();
182+
unset($mp);
183+
184+
// Corrupt the cached file so the regex can't extract lastBuildDate.
185+
$cache_files = glob(temp_xml_glob());
186+
$this->assertNotEmpty($cache_files);
187+
$cache_file = $cache_files[0];
188+
$corrupted = preg_replace(
189+
'#<lastBuildDate>.*?</lastBuildDate>#',
190+
'',
191+
file_get_contents($cache_file)
192+
);
193+
file_put_contents($cache_file, $corrupted);
194+
$expected_ts = filemtime($cache_file);
195+
196+
$mp2 = $this->newPodcast(3600);
197+
$this->assertTrue($mp2->isCached());
198+
$mp2->generate();
199+
200+
$http_last_modified = $mp2->httpLastModified();
201+
$this->assertNotNull($http_last_modified);
202+
$this->assertEquals(
203+
gmdate('D, d M Y H:i:s', $expected_ts) . ' GMT',
204+
$http_last_modified
205+
);
206+
}
207+
171208
public function tearDown(): void
172209
{
173210
file_exists('extra.mp3') && unlink('extra.mp3');
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php declare(strict_types=1);
2+
3+
use PHPUnit\Framework\TestCase;
4+
5+
class Caching_updates_to_local_iniTest extends TestCase
6+
{
7+
public $file = 'out.xml';
8+
public $output = '';
9+
public $returncode = 0;
10+
11+
public $content = '';
12+
13+
public function setUp(): void
14+
{
15+
prepare_testing_dir();
16+
17+
if(is_link('dir2cast.php')) {
18+
$source_file = readlink('dir2cast.php');
19+
unlink('dir2cast.php');
20+
copy($source_file, 'dir2cast.php');
21+
}
22+
23+
// No global ini for this test - we want to test only the per-MP3-dir ini.
24+
if(file_exists('dir2cast.ini')) {
25+
unlink('dir2cast.ini');
26+
}
27+
28+
// Pre-populate the media dir with one episode old enough to make it into the
29+
// feed. We deliberately do NOT age_dir_by(.) here, because doing so would
30+
// change empty.mp3's mtime and thus the item_hash on the next scan, which
31+
// would invalidate the cache for reasons unrelated to the local ini.
32+
mkdir('media');
33+
copy('../fixtures/empty.mp3', 'media/empty.mp3');
34+
touch('media/empty.mp3', time() - 86400);
35+
36+
// First-pass cache must reflect default settings (no local ini yet).
37+
exec('php dir2cast.php --media-dir=media --output=out.xml --dont-uncache --min-file-age=30 --ignore-dir2cast-mtime', $this->output, $this->returncode);
38+
$this->content = file_get_contents($this->file);
39+
40+
// Sanity check: the feed used the default language.
41+
$this->assertStringContainsString('<language>en-us</language>', $this->content);
42+
}
43+
44+
public function test_per_dir_ini_change_invalidates_cache(): void
45+
{
46+
// Drop a per-MP3-dir ini that overrides LANGUAGE. Touch it newer than the
47+
// cached output but with empty.mp3's mtime untouched (so item_hash stays
48+
// the same). The only signal the cache layer has to regenerate is that
49+
// the local ini was modified - which is exactly what we want to verify.
50+
file_put_contents('media/dir2cast.ini', "LANGUAGE = \"fr-fr\"\n");
51+
52+
$cache_files = glob('temp' . DIRECTORY_SEPARATOR . '*.xml');
53+
$this->assertNotEmpty($cache_files);
54+
$cache_file = $cache_files[0];
55+
56+
// We deliberately do NOT use --ignore-dir2cast-mtime here, so the dispatcher
57+
// tracks both dir2cast.php's mtime and the local ini's mtime. To prove the
58+
// ini specifically is what triggers invalidation, age dir2cast.php older
59+
// than the cache: the only signal newer than the cache is the ini.
60+
$now = time();
61+
touch('dir2cast.php', $now - 86400);
62+
touch($cache_file, $now - 1000);
63+
touch('media/dir2cast.ini', $now - 30);
64+
clearstatcache();
65+
66+
exec('php dir2cast.php --media-dir=media --output=out.xml --dont-uncache --min-file-age=30 --debug', $debug_out);
67+
68+
$new_content = file_get_contents($this->file);
69+
$this->assertStringContainsString(
70+
'<language>fr-fr</language>',
71+
$new_content,
72+
"Local dir2cast.ini change must invalidate the cache. Debug output:\n" . implode("\n", $debug_out)
73+
);
74+
}
75+
76+
public function tearDown(): void
77+
{
78+
file_exists($this->file) && unlink($this->file);
79+
chdir('..');
80+
}
81+
}

0 commit comments

Comments
 (0)