From 34b503cfcb25f114b36e450613c83276cf9e09db Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Wed, 11 Feb 2026 12:53:36 +0100 Subject: [PATCH 1/2] ARSN-552: don't throw in case of bad Date inputs --- lib/auth/auth.ts | 4 +- lib/auth/v4/headerAuthCheck.ts | 18 ++--- lib/auth/v4/timeUtils.ts | 89 +++++++++++++++++++--- tests/unit/auth/v4/headerAuthCheck.spec.js | 8 +- tests/unit/auth/v4/timeUtils.spec.js | 75 +++++++++++++++--- 5 files changed, 156 insertions(+), 38 deletions(-) diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 57f915566..5795f08fb 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -216,7 +216,9 @@ function generateV4Headers( payload?: string, ) { Object.assign(request, { headers: {} }); - const amzDate = convertUTCtoISO8601(Date.now()); + // Date.now() should always return a valid date so we assert non null. + const amzDate = convertUTCtoISO8601(Date.now())!; + // get date without time const scopeDate = amzDate.slice(0, amzDate.indexOf('T')); const region = 'us-east-1'; diff --git a/lib/auth/v4/headerAuthCheck.ts b/lib/auth/v4/headerAuthCheck.ts index 1086fe0ae..85f9b547f 100644 --- a/lib/auth/v4/headerAuthCheck.ts +++ b/lib/auth/v4/headerAuthCheck.ts @@ -6,6 +6,7 @@ import { checkTimeSkew, convertUTCtoISO8601, convertAmzTimeToMs, + isValidISO8601Compact, } from './timeUtils'; import { extractAuthItems, @@ -82,20 +83,19 @@ export function check( // check request timestamp const xAmzDate = request.headers['x-amz-date']; if (xAmzDate) { - const xAmzDateArr = xAmzDate.split('T'); - // check that x-amz- date has the correct format and after epochTime - if (xAmzDateArr.length === 2 && xAmzDateArr[0].length === 8 - && xAmzDateArr[1].length === 7 - && Number.parseInt(xAmzDateArr[0], 10) > 19700101) { - // format of x-amz- date is ISO 8601: YYYYMMDDTHHMMSSZ - timestamp = request.headers['x-amz-date']; + if (isValidISO8601Compact(xAmzDate)) { + timestamp = xAmzDate; } } else if (request.headers.date) { - timestamp = convertUTCtoISO8601(request.headers.date); + if (isValidISO8601Compact(request.headers.date)) { + timestamp = request.headers.date; + } else { + timestamp = convertUTCtoISO8601(request.headers.date); + } } if (!timestamp) { log.debug('missing or invalid date header', - { method: 'auth/v4/headerAuthCheck.check' }); + { 'method': 'auth/v4/headerAuthCheck.check', 'x-amz-date': xAmzDate, 'Date': request.headers.date }); return { err: errorInstances.AccessDenied. customizeDescription('Authentication requires a valid Date or ' + 'x-amz-date header') }; diff --git a/lib/auth/v4/timeUtils.ts b/lib/auth/v4/timeUtils.ts index c76e12c69..fd3383ae0 100644 --- a/lib/auth/v4/timeUtils.ts +++ b/lib/auth/v4/timeUtils.ts @@ -15,15 +15,33 @@ export function convertAmzTimeToMs(timestamp: string) { } /** - * Convert UTC timestamp to ISO 8601 timestamp - * @param timestamp of UTC form: Fri, 10 Feb 2012 21:34:55 GMT - * @return ISO8601 timestamp of form: YYYYMMDDTHHMMSSZ - */ -export function convertUTCtoISO8601(timestamp: string | number) { - // convert to ISO string: YYYY-MM-DDTHH:mm:ss.sssZ. - const converted = new Date(timestamp).toISOString(); - // Remove "-"s and "."s and milliseconds - return converted.split('.')[0].replace(/-|:/g, '').concat('Z'); +* Convert UTC timestamp to ISO 8601 compact format +* @param timestamp - UTC timestamp (e.g., 'Fri, 10 Feb 2012 21:34:55 GMT') or Unix timestamp +* @return ISO8601 timestamp of form YYYYMMDDTHHMMSSZ, or undefined if invalid +* +* @example +* convertUTCtoISO8601('Fri, 10 Feb 2012 21:34:55 GMT'); // '20120210T213455Z' +* convertUTCtoISO8601(1328910895000); // '20120210T213455Z' +* convertUTCtoISO8601('invalid'); // undefined +*/ +export function convertUTCtoISO8601(timestamp: string | number): string | undefined { + if (timestamp == null) { + return undefined; + } + + const date = new Date(timestamp); + + if (isNaN(date.getTime())) { + return undefined; + } + + try { + // Can throw RangeError. + const converted = date.toISOString(); + return converted.split('.')[0].replace(/-|:/g, '').concat('Z'); + } catch { + return undefined; + } } /** @@ -41,7 +59,8 @@ export function checkTimeSkew(timestamp: string, expiry: number, log: RequestLog if ((currentTime + fifteenMinutes) < parsedTimestamp) { log.debug('current time pre-dates timestamp', { parsedTimestamp, - currentTimeInMilliseconds: currentTime }); + currentTimeInMilliseconds: currentTime + }); return true; } const expiryInMilliseconds = expiry * 1000; @@ -49,8 +68,56 @@ export function checkTimeSkew(timestamp: string, expiry: number, log: RequestLog log.debug('signature has expired', { parsedTimestamp, expiry, - currentTimeInMilliseconds: currentTime }); + currentTimeInMilliseconds: currentTime + }); return true; } return false; } + +/** +* Validates if a string is in ISO 8601 compact format: YYYYMMDDTHHMMSSZ +* +* Checks that: +* - String is exactly 16 characters long +* - Format matches YYYYMMDDTHHMMSSZ (8 digits, 'T', 6 digits, 'Z') +* - All date/time components are valid (no Feb 30th, no 25:00:00, etc.) +* - No silent date corrections occur (prevents rollover) +* +* @param str - The string to validate +* @returns true if the string is a valid ISO 8601 compact format, false otherwise +* +* @example +* ```typescript +* isValidISO8601Compact('20160208T201405Z'); // true +* isValidISO8601Compact('20160230T201405Z'); // false (Feb 30 invalid) +* isValidISO8601Compact('20160208T251405Z'); // false (25 hours invalid) +* isValidISO8601Compact('2016-02-08T20:14:05Z'); // false (wrong format) +* isValidISO8601Compact('abcd0208T201405Z'); // false (contains letters) +* ``` +*/ +export function isValidISO8601Compact(str: string): boolean { + if (str == null || typeof str !== 'string') { + return false; + } + + // Match format: YYYYMMDDTHHMMSSZ + const match = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (!match) { + return false; + } + + const [, year, month, day, hour, minute, second] = match; + + // Construct standard ISO format and validate + const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`; + const date = new Date(isoString); + + try { + // date.toISOString() can throw. + // date.toISOString() === isoString check prevents silent date corrections (30 February to 1 March) + return !Number.isNaN(date.getTime()) && date.toISOString() === isoString; + } catch { + return false; + } +} diff --git a/tests/unit/auth/v4/headerAuthCheck.spec.js b/tests/unit/auth/v4/headerAuthCheck.spec.js index a86634ec8..0aea4f137 100644 --- a/tests/unit/auth/v4/headerAuthCheck.spec.js +++ b/tests/unit/auth/v4/headerAuthCheck.spec.js @@ -179,19 +179,17 @@ describe('v4 headerAuthCheck', () => { it('should return error if timestamp from x-amz-date header' + 'is before epochTime', done => { - // Different date (2095 instead of 2016) + // Date from 1950 (before epoch time) const alteredRequest = createAlteredRequest({ 'x-amz-date': '19500707T215304Z', 'authorization': 'AWS4-HMAC-SHA256 Credential' + - '=accessKey1/20160208/us-east-1/s3/aws4_request, ' + + '=accessKey1/19500707/us-east-1/s3/aws4_request, ' + 'SignedHeaders=host;x-amz-content-sha256;' + 'x-amz-date, Signature=abed924c06abf8772c67' + '0064d22eacd6ccb85c06befa15f' + '4a789b0bae19307bc' }, 'headers', request, headers); const res = headerAuthCheck(alteredRequest, log); - assert.deepStrictEqual(res.err, errorInstances.AccessDenied. - customizeDescription('Authentication requires a valid Date or ' + - 'x-amz-date header')); + assert.deepStrictEqual(res.err, errors.RequestTimeTooSkewed); done(); }); diff --git a/tests/unit/auth/v4/timeUtils.spec.js b/tests/unit/auth/v4/timeUtils.spec.js index 1a0aefc5e..511b4962d 100644 --- a/tests/unit/auth/v4/timeUtils.spec.js +++ b/tests/unit/auth/v4/timeUtils.spec.js @@ -3,12 +3,13 @@ const assert = require('assert'); const fakeTimers = require('@sinonjs/fake-timers'); -const checkTimeSkew = - require('../../../../lib/auth/v4/timeUtils').checkTimeSkew; -const convertAmzTimeToMs = - require('../../../../lib/auth/v4/timeUtils').convertAmzTimeToMs; -const convertUTCtoISO8601 = - require('../../../../lib/auth/v4/timeUtils').convertUTCtoISO8601; +const { + checkTimeSkew, + convertAmzTimeToMs, + convertUTCtoISO8601, + isValidISO8601Compact, +} = require('../../../../lib/auth/v4/timeUtils'); + const DummyRequestLogger = require('../../helpers').DummyRequestLogger; const log = new DummyRequestLogger(); @@ -25,12 +26,62 @@ describe('convertAmzTimeToMs function', () => { }); describe('convertUTCtoISO8601 function', () => { - it('should UTC timestamp to ISO8601 timestamp', () => { - const input = 'Sun, 08 Feb 2015 20:14:05 GMT'; - const expectedOutput = '20150208T201405Z'; - const actualOutput = convertUTCtoISO8601(input); - assert.strictEqual(actualOutput, expectedOutput); - }); + [ + { + name: 'should convert UTC string to ISO8601', + input: 'Sun, 08 Feb 2015 20:14:05 GMT', + expected: '20150208T201405Z', + }, + { + name: 'should convert Unix timestamp to ISO8601', + input: Date.UTC(2015, 1, 8, 20, 14, 5), + expected: '20150208T201405Z', + }, + { + name: 'should handle ISO8601 string with dashes/colons', + input: '2016-02-08T20:14:05Z', + expected: '20160208T201405Z', + }, + { name: 'should return undefined for invalid string', input: 'invalid date string', expected: undefined }, + { name: 'should return undefined for compact ISO8601 input', input: '20160208T201405Z', expected: undefined }, + { name: 'should return undefined for null', input: null, expected: undefined }, + { name: 'should return undefined for undefined', input: undefined, expected: undefined }, + { name: 'should return undefined for NaN', input: NaN, expected: undefined }, + { name: 'should return undefined for object', input: {}, expected: undefined }, + ].forEach(t => it(t.name, () => { + assert.strictEqual(convertUTCtoISO8601(t.input), t.expected); + })); +}); + +describe('isValidISO8601Compact function', () => { + [ + { name: 'should return true for valid ISO8601 compact format', input: '20160208T201405Z', expected: true }, + { name: 'should return true for valid timestamp with zeros', input: '20200101T000000Z', expected: true }, + { name: 'should return true for valid timestamp at end of day', input: '20201231T235959Z', expected: true }, + { name: 'should return true for leap year Feb 29', input: '20200229T120000Z', expected: true }, + { name: 'should return false for string with wrong length', input: '2016020T201405Z', expected: false }, + { name: 'should return false for ISO8601 with dashes/colons', input: '2016-02-08T20:14:05Z', expected: false }, + { name: 'should return false for missing T separator', input: '20160208 201405Z', expected: false }, + { name: 'should return false for missing Z suffix', input: '20160208T201405', expected: false }, + { name: 'should return false for string with letters in date', input: 'abcd0208T201405Z', expected: false }, + { name: 'should return false for empty string', input: '', expected: false }, + { name: 'should return false for invalid month (13)', input: '20161308T201405Z', expected: false }, + { name: 'should return false for invalid day (32)', input: '20160232T201405Z', expected: false }, + { name: 'should return false for Feb 30 (invalid)', input: '20160230T201405Z', expected: false }, + { name: 'should return false for Feb 29 in non-leap year', input: '20190229T201405Z', expected: false }, + { name: 'should return false for invalid hour (25)', input: '20160208T251405Z', expected: false }, + { name: 'should return false for invalid minute (60)', input: '20160208T206005Z', expected: false }, + { name: 'should return false for invalid second (60)', input: '20160208T201460Z', expected: false }, + { name: 'should return false for month 00', input: '20160008T201405Z', expected: false }, + { name: 'should return false for day 00', input: '20160200T201405Z', expected: false }, + { name: 'should return false for null', input: null, expected: false }, + { name: 'should return false for undefined', input: undefined, expected: false }, + { name: 'should return false for number', input: 20160208201405, expected: false }, + { name: 'should return false for object', input: {}, expected: false }, + { name: 'should return false for array', input: [], expected: false }, + ].forEach(t => it(t.name, () => { + assert.strictEqual(isValidISO8601Compact(t.input), t.expected); + })); }); describe('checkTimeSkew function', () => { From a81fb2dd30a8104d11b20d39c97ec3458365439c Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Wed, 11 Feb 2026 16:32:41 +0100 Subject: [PATCH 2/2] ARSN-552: bump package.json version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d8c1e1ec4..f1d4cad54 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "engines": { "node": ">=20" }, - "version": "8.2.45", + "version": "8.2.46", "description": "Common utilities for the S3 project components", "main": "build/index.js", "repository": {