Skip to content

Commit 7679452

Browse files
davewoloszynandrewnicols
authored and
Jenkins
committed
MDL-77846 core: Make endpoint revision number checks stricter
In some places we prevented cache poisoning, in others we did not. We also did not place any restriction on the minimum value for a revision. This change introduces a new set of functions for configonly endpoints which validates the revision numbers passed in. If the revision is either too old, or too new, it is rejected and the file content is not cached. The content is still served, but caching headers are not sent, and any local storage caching is prevented. The current time is used as the maximum version, with 60 seconds added to allow for any clock skew between cluster nodes. Previously some locations used one hour, but there should never be such a large clock skew on a correctly configured system. Co-authored-by: Andrew Nicols <[email protected]>
1 parent 5a765e1 commit 7679452

10 files changed

+164
-9
lines changed

lib/configonlylib.php

+54
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,57 @@ function min_get_slash_argument($clean = true) {
205205
}
206206
return $relativepath;
207207
}
208+
209+
/**
210+
* Get the lowest possible currently valid revision number.
211+
*
212+
* This is based on the current Moodle version.
213+
*
214+
* @return int Unix timestamp
215+
*/
216+
function min_get_minimum_revision(): int {
217+
static $timestamp = null;
218+
219+
if ($timestamp === null) {
220+
global $CFG;
221+
require("{$CFG->dirroot}/version.php");
222+
// Get YYYYMMDD from version.
223+
$datestring = floor($version / 100);
224+
// Parse the date components.
225+
$year = intval(substr($datestring, 0, 4));
226+
$month = intval(substr($datestring, 4, 2));
227+
$day = intval(substr($datestring, 6, 2));
228+
// Return converted GMT Unix timestamp.
229+
$timestamp = gmmktime(0, 0, 0, $month, $day, $year);
230+
}
231+
232+
return $timestamp;
233+
}
234+
235+
/**
236+
* Get the highest possible currently valid revision number.
237+
*
238+
* This is based on the current time, allowing for a small amount of clock skew between servers.
239+
*
240+
* Future values beyond the clock skew are not allowed to avoid the possibility of cache poisoning.
241+
*
242+
* @return int
243+
*/
244+
function min_get_maximum_revision(): int {
245+
return time() + 60;
246+
}
247+
248+
/**
249+
* Helper function to determine if the given revision number is valid.
250+
*
251+
* @param int $revision A numeric revision to check for validity
252+
* @return bool Whether the revision is valid
253+
*/
254+
function min_is_revision_valid_and_current(int $revision): bool {
255+
// Invalid revision.
256+
if ($revision <= 0) {
257+
return false;
258+
}
259+
// Check revision is within range.
260+
return $revision >= min_get_minimum_revision() && $revision <= min_get_maximum_revision();
261+
}

lib/editor/tiny/lang.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ protected function parse_file_information_from_url(): void {
9595
*/
9696
protected function serve_file(): void {
9797
// Attempt to send the cached langpack.
98-
if ($this->rev > 0) {
98+
// We only cache the file if the rev is valid.
99+
if (min_is_revision_valid_and_current($this->rev)) {
99100
if ($this->is_candidate_file_available()) {
100101
// The send_cached_file_if_available function will exit if successful.
101102
// In theory the file could become unavailable after checking that the file exists.

lib/editor/tiny/loader.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ protected function parse_file_information_from_url(): void {
114114
*/
115115
public function serve_file(): void {
116116
// Attempt to send the cached filepathpack.
117-
if ($this->rev > 0) {
117+
// We only cache the file if the rev is valid.
118+
if (min_is_revision_valid_and_current($this->rev)) {
118119
if ($this->is_candidate_file_available()) {
119120
// The send_cached_file_if_available function will exit if successful.
120121
// In theory the file could become unavailable after checking that the file exists.

lib/javascript.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
$file = min_optional_param('jsfile', '', 'RAW'); // 'file' would collide with URL rewriting!
4848
}
4949

50+
if (!min_is_revision_valid_and_current($rev)) {
51+
// If the rev is invalid, normalise it to -1 to disable all caching.
52+
$rev = -1;
53+
}
54+
5055
// some security first - pick only files with .js extension in dirroot
5156
$jsfiles = array();
5257
$files = explode(',', $file);
@@ -79,8 +84,7 @@
7984

8085
$etag = sha1($rev.implode(',', $jsfiles));
8186

82-
// Use the caching only for meaningful revision numbers which prevents future cache poisoning.
83-
if ($rev > 0 and $rev < (time() + 60*60)) {
87+
if ($rev > 0) {
8488
$candidate = $CFG->localcachedir.'/js/'.$etag;
8589

8690
if (file_exists($candidate)) {

lib/tests/configonlylib_test.php

+72-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
// Global $CFG not used here intentionally to make sure it is not required inside the lib.
2222
require_once(__DIR__ . '/../configonlylib.php');
2323

24-
2524
/**
2625
* Unit tests for config only library functions.
2726
*
@@ -144,4 +143,76 @@ public function test_min_get_slash_argument() {
144143
// Windows dir separators are removed, multiple ... gets collapsed to one .
145144
$this->assertSame('/standardcore/./5/u/f1', min_get_slash_argument());
146145
}
146+
147+
/**
148+
* Test the min_get_minimum_version function.
149+
*
150+
* @covers ::min_get_minimum_version
151+
*/
152+
public function test_min_get_minimum_version(): void {
153+
// This is fairly hard to write a test for, but we can at least check that it returns a number
154+
// greater than the version when the feature was first introduced.
155+
$firstintroduced = 1693612800; // Equivalent to 20230902 00:00:00 GMT.
156+
$this->assertGreaterThanOrEqual($firstintroduced, min_get_minimum_revision());
157+
}
158+
159+
/**
160+
* Test the min_get_maximum_version function.
161+
*
162+
* @covers ::min_get_maximum_version
163+
*/
164+
public function test_min_get_maximum_version(): void {
165+
// The maximum version should be set to a time in the near future.
166+
// This is currently defined as "in the next minute".
167+
// Note: We use a 65 second window to allow for slow test runners.
168+
$this->assertGreaterThan(time(), min_get_maximum_revision());
169+
$this->assertLessThanOrEqual(time() + 65, min_get_maximum_revision());
170+
}
171+
172+
/**
173+
* Test the min_is_revision_valid_and_current function.
174+
*
175+
* @covers ::min_is_revision_valid_and_current
176+
* @dataProvider min_is_revision_valid_and_current_provider
177+
*/
178+
public function test_min_is_revision_valid_and_current(int $revision, bool $expected): void {
179+
$this->assertEquals($expected, min_is_revision_valid_and_current($revision));
180+
}
181+
182+
/**
183+
* Data provider for the min_is_revision_valid_and_current tests.
184+
*
185+
* @return array
186+
*/
187+
public function min_is_revision_valid_and_current_provider(): array {
188+
return [
189+
'Negative value' => [-1, false],
190+
'Empty value' => [0, false],
191+
'A time before the minimum accepted value' => [min_get_minimum_revision() - 1, false],
192+
'The minimum accepted value' => [min_get_minimum_revision(), true],
193+
'The current time' => [time(), true],
194+
// Note: We have to be careful using time values because the data provider is run at the start of the phpunit run,
195+
// but the test may not be run for some time.
196+
// On a slower machine and/or database, this could be several hours.
197+
// For a more specific time we must have a specific test function.
198+
'A time in the future' => [time() + DAYSECS, false]
199+
];
200+
}
201+
202+
203+
/**
204+
* Test the min_is_revision_valid_and_current function with close times.
205+
*
206+
* Note: These tests are incompatible with data providers.
207+
*
208+
* @covers ::min_is_revision_valid_and_current
209+
*/
210+
public function test_min_is_revision_valid_and_current_close_proximity(): void {
211+
// A time in the near future.
212+
$this->assertTrue(min_is_revision_valid_and_current(time() + 55));
213+
214+
// A time in the too-far future.
215+
$this->assertFalse(min_is_revision_valid_and_current(time() + 70));
216+
217+
}
147218
}

theme/font.php

+5
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
$font = min_optional_param('font', '', 'RAW');
5050
}
5151

52+
if (!min_is_revision_valid_and_current($rev)) {
53+
// If the rev is invalid, normalise it to -1 to disable all caching.
54+
$rev = -1;
55+
}
56+
5257
if (!$font) {
5358
font_not_found();
5459
}

theme/image.php

+5
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@
5858
$usesvg = (bool)min_optional_param('svg', '1', 'INT');
5959
}
6060

61+
if (!min_is_revision_valid_and_current($rev)) {
62+
// If the rev is invalid, normalise it to -1 to disable all caching.
63+
$rev = -1;
64+
}
65+
6166
if (empty($component) or $component === 'moodle' or $component === 'core') {
6267
$component = 'core';
6368
}

theme/javascript.php

+5
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
$type = min_optional_param('type', 'head', 'RAW');
5151
}
5252

53+
if (!min_is_revision_valid_and_current($rev)) {
54+
// If the rev is invalid, normalise it to -1 to disable all caching.
55+
$rev = -1;
56+
}
57+
5358
if ($type !== 'head' and $type !== 'footer') {
5459
header('HTTP/1.0 404 not found');
5560
die('Theme was not found, sorry.');

theme/styles.php

+7
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@
6666
$themesubrev = min_clean_param($themesubrev, 'INT');
6767
}
6868

69+
// Note: We only check validity of the revision number here, we do not check the theme sub-revision because this is
70+
// not solely based on time.
71+
if (!min_is_revision_valid_and_current($rev)) {
72+
// If the rev is invalid, normalise it to -1 to disable all caching.
73+
$rev = -1;
74+
}
75+
6976
// Check that type fits into the expected values.
7077
if (!in_array($type, ['all', 'all-rtl', 'editor', 'editor-rtl'])) {
7178
css_send_css_not_found();

theme/yui_combo.php

+6-4
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,9 @@
231231
continue;
232232
}
233233
$revision = (int)array_shift($bits);
234-
if ($revision === -1) {
235-
// Revision -1 says please don't cache the JS
234+
if (!min_is_revision_valid_and_current($revision)) {
235+
// A non-current revision means please don't cache the JS
236+
$revision = -1;
236237
$cache = false;
237238
}
238239
$frankenstyle = array_shift($bits);
@@ -278,8 +279,9 @@
278279
continue;
279280
}
280281
$revision = (int)array_shift($bits);
281-
if ($revision === -1) {
282-
// Revision -1 says please don't cache the JS
282+
if (!min_is_revision_valid_and_current($revision)) {
283+
// A non-current revision means please don't cache the JS
284+
$revision = -1;
283285
$cache = false;
284286
}
285287
$contentfile = "$CFG->libdir/yuilib/gallery/" . join('/', $bits);

0 commit comments

Comments
 (0)