diff --git a/lib/auth/v4/headerAuthCheck.ts b/lib/auth/v4/headerAuthCheck.ts index f08812f46..d9e4e06c9 100644 --- a/lib/auth/v4/headerAuthCheck.ts +++ b/lib/auth/v4/headerAuthCheck.ts @@ -5,8 +5,7 @@ import constructStringToSign from './constructStringToSign'; import { checkTimeSkew, convertUTCtoISO8601, - convertAmzTimeToMs, - isValidISO8601Compact, + parseISO8601Compact, } from './timeUtils'; import { extractAuthItems, @@ -80,33 +79,46 @@ export function check( return { err: errors.AccessDenied }; } - let timestamp: string | undefined; + let timestampCompact: string | undefined; + let timestampParsed: Date | undefined; + // check request timestamp const xAmzDate = request.headers['x-amz-date']; if (xAmzDate) { - if (isValidISO8601Compact(xAmzDate)) { - timestamp = xAmzDate; + const parsed = parseISO8601Compact(xAmzDate); + if (parsed) { + timestampCompact = xAmzDate; + timestampParsed = parsed; } } else if (request.headers.date) { - if (isValidISO8601Compact(request.headers.date)) { - timestamp = request.headers.date; + const parsed = parseISO8601Compact(request.headers.date); + if (parsed) { + timestampCompact = request.headers.date; + timestampParsed = parsed; } else { - timestamp = convertUTCtoISO8601(request.headers.date); + const converted = convertUTCtoISO8601(request.headers.date); + if (converted) { + const convertedParsed = parseISO8601Compact(converted); + if (convertedParsed) { + timestampCompact = converted; + timestampParsed = convertedParsed; + } + } } } - if (!timestamp) { + const beforeEpoch = timestampParsed && timestampParsed.getTime() < 0; + if (!timestampCompact || !timestampParsed || beforeEpoch) { log.debug('missing or invalid date header', { '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') }; + return { + err: errorInstances.AccessDenied. + customizeDescription('Authentication requires a valid Date or x-amz-date header') + }; } - const validationResult = validateCredentials(credentialsArr, timestamp, - log); + const validationResult = validateCredentials(credentialsArr, timestampCompact, log); if (validationResult instanceof ArsenalError) { - log.debug('credentials in improper format', { credentialsArr, - timestamp, validationResult }); + log.debug('credentials in improper format', { credentialsArr, timestamp: timestampCompact, validationResult }); return { err: validationResult }; } // credentialsArr is [accessKey, date, region, aws-service, aws4_request] @@ -128,7 +140,7 @@ export function check( // note that expiration can be shortened so // expiry is as set out in the policy. - const isTimeSkewed = checkTimeSkew(timestamp, constants.requestExpirySeconds, log); + const isTimeSkewed = checkTimeSkew(timestampCompact, constants.requestExpirySeconds, log); if (isTimeSkewed) { return { err: errors.RequestTimeTooSkewed }; } @@ -139,8 +151,7 @@ export function check( proxyPath = decodeURIComponent(request.headers.proxy_path); } catch (err) { log.debug('invalid proxy_path header', { proxyPath, err }); - return { err: errorInstances.InvalidArgument.customizeDescription( - 'invalid proxy_path header') }; + return { err: errorInstances.InvalidArgument.customizeDescription('invalid proxy_path header') }; } } @@ -150,7 +161,7 @@ export function check( query: data, signedHeaders, credentialScope, - timestamp, + timestamp: timestampCompact, payloadChecksum, awsService: service, proxyPath, @@ -171,11 +182,11 @@ export function check( stringToSign, authType: 'REST-HEADER', signatureVersion: 'AWS4-HMAC-SHA256', - signatureAge: Date.now() - convertAmzTimeToMs(timestamp), + signatureAge: Date.now() - timestampParsed.getTime(), // credentialScope and timestamp needed for streaming V4 // chunk evaluation credentialScope, - timestamp, + timestamp: timestampCompact, securityToken: token, }, }, diff --git a/lib/auth/v4/timeUtils.ts b/lib/auth/v4/timeUtils.ts index 59ad3ee61..abd4efa5f 100644 --- a/lib/auth/v4/timeUtils.ts +++ b/lib/auth/v4/timeUtils.ts @@ -75,36 +75,30 @@ export function checkTimeSkew(timestamp: string, expiry: number, log: RequestLog 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 { + * Parses an ISO 8601 compact timestamp string into a Date object. + * + * @param str - The string to parse + * @returns A Date object if the string is a valid ISO 8601 compact timestamp, undefined otherwise + * + * @example + * ```typescript + * parseISO8601Compact('20160208T201405Z'); // Date object + * parseISO8601Compact('19500707T215304Z'); // Date object (pre-Unix epoch) + * parseISO8601Compact('20160230T201405Z'); // undefined (Feb 30 invalid) + * parseISO8601Compact('invalid'); // undefined + * ``` + */ +export function parseISO8601Compact(str: string): Date | undefined { if (typeof str !== 'string') { - return false; + return undefined; } // 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; + return undefined; } const [, year, month, day, hour, minute, second] = match; @@ -114,10 +108,19 @@ export function isValidISO8601Compact(str: string): boolean { const date = new Date(isoString); try { + if (Number.isNaN(date.getTime())) { + return undefined; + } + // 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; + if (date.toISOString() !== isoString) { + return undefined; + } + + return date; } catch { - return false; + return undefined; } } + diff --git a/package.json b/package.json index 18529b85e..17f2cb5dc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "engines": { "node": ">=20" }, - "version": "8.3.7", + "version": "8.3.8", "description": "Common utilities for the S3 project components", "main": "build/index.js", "repository": { diff --git a/tests/unit/auth/v4/headerAuthCheck.spec.js b/tests/unit/auth/v4/headerAuthCheck.spec.js index 0aea4f137..a834d9e9e 100644 --- a/tests/unit/auth/v4/headerAuthCheck.spec.js +++ b/tests/unit/auth/v4/headerAuthCheck.spec.js @@ -177,7 +177,7 @@ describe('v4 headerAuthCheck', () => { done(); }); - it('should return error if timestamp from x-amz-date header' + + it('should return AccessDenied if timestamp from x-amz-date header' + 'is before epochTime', done => { // Date from 1950 (before epoch time) const alteredRequest = createAlteredRequest({ @@ -189,7 +189,9 @@ describe('v4 headerAuthCheck', () => { '0064d22eacd6ccb85c06befa15f' + '4a789b0bae19307bc' }, 'headers', request, headers); const res = headerAuthCheck(alteredRequest, log); - assert.deepStrictEqual(res.err, errors.RequestTimeTooSkewed); + assert.deepStrictEqual(res.err, errorInstances.AccessDenied. + customizeDescription('Authentication requires a valid Date or ' + + 'x-amz-date header')); done(); }); diff --git a/tests/unit/auth/v4/timeUtils.spec.js b/tests/unit/auth/v4/timeUtils.spec.js index 511b4962d..4293efc55 100644 --- a/tests/unit/auth/v4/timeUtils.spec.js +++ b/tests/unit/auth/v4/timeUtils.spec.js @@ -7,7 +7,7 @@ const { checkTimeSkew, convertAmzTimeToMs, convertUTCtoISO8601, - isValidISO8601Compact, + parseISO8601Compact, } = require('../../../../lib/auth/v4/timeUtils'); const DummyRequestLogger = require('../../helpers').DummyRequestLogger; @@ -53,34 +53,43 @@ describe('convertUTCtoISO8601 function', () => { })); }); -describe('isValidISO8601Compact function', () => { +describe('parseISO8601Compact 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 }, + { name: 'should return a Date for valid ISO8601 compact format', input: '20160208T201405Z', expected: new Date('2016-02-08T20:14:05Z') }, + { name: 'should return a Date for valid timestamp with zeros', input: '20200101T000000Z', expected: new Date('2020-01-01T00:00:00Z') }, + { name: 'should return a Date for valid timestamp at end of day', input: '20201231T235959Z', expected: new Date('2020-12-31T23:59:59Z') }, + { name: 'should return a Date for leap year Feb 29', input: '20200229T120000Z', expected: new Date('2020-02-29T12:00:00Z') }, + { name: 'should return a Date for pre-epoch date (1950)', input: '19500707T215304Z', expected: new Date('1950-07-07T21:53:04Z') }, + { name: 'should return a Date for pre-epoch date (1969)', input: '19691231T235959Z', expected: new Date('1969-12-31T23:59:59Z') }, + { name: 'should return a Date for Unix epoch start (1970)', input: '19700101T000000Z', expected: new Date('1970-01-01T00:00:00Z') }, + { name: 'should return undefined for string with wrong length', input: '2016020T201405Z', expected: undefined }, + { name: 'should return undefined for ISO8601 with dashes/colons', input: '2016-02-08T20:14:05Z', expected: undefined }, + { name: 'should return undefined for missing T separator', input: '20160208 201405Z', expected: undefined }, + { name: 'should return undefined for missing Z suffix', input: '20160208T201405', expected: undefined }, + { name: 'should return undefined for string with letters in date', input: 'abcd0208T201405Z', expected: undefined }, + { name: 'should return undefined for empty string', input: '', expected: undefined }, + { name: 'should return undefined for invalid month (13)', input: '20161308T201405Z', expected: undefined }, + { name: 'should return undefined for invalid day (32)', input: '20160232T201405Z', expected: undefined }, + { name: 'should return undefined for Feb 30 (invalid)', input: '20160230T201405Z', expected: undefined }, + { name: 'should return undefined for Feb 29 in non-leap year', input: '20190229T201405Z', expected: undefined }, + { name: 'should return undefined for invalid hour (25)', input: '20160208T251405Z', expected: undefined }, + { name: 'should return undefined for invalid minute (60)', input: '20160208T206005Z', expected: undefined }, + { name: 'should return undefined for invalid second (60)', input: '20160208T201460Z', expected: undefined }, + { name: 'should return undefined for month 00', input: '20160008T201405Z', expected: undefined }, + { name: 'should return undefined for day 00', input: '20160200T201405Z', 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 number', input: 20160208201405, expected: undefined }, + { name: 'should return undefined for object', input: {}, expected: undefined }, + { name: 'should return undefined for array', input: [], expected: undefined }, ].forEach(t => it(t.name, () => { - assert.strictEqual(isValidISO8601Compact(t.input), t.expected); + const result = parseISO8601Compact(t.input); + if (t.expected instanceof Date) { + assert.ok(result instanceof Date, `expected Date, got ${result}`); + assert.strictEqual(result.getTime(), t.expected.getTime()); + } else { + assert.strictEqual(result, undefined); + } })); });