Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 9 additions & 9 deletions lib/auth/v4/headerAuthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
checkTimeSkew,
convertUTCtoISO8601,
convertAmzTimeToMs,
isValidISO8601Compact,
} from './timeUtils';
import {
extractAuthItems,
Expand Down Expand Up @@ -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') };
Expand Down
89 changes: 78 additions & 11 deletions lib/auth/v4/timeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand All @@ -41,16 +59,65 @@ 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;
if (currentTime > parsedTimestamp + expiryInMilliseconds) {
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') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: typeof str !== 'string' would suffice

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;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 3 additions & 5 deletions tests/unit/auth/v4/headerAuthCheck.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
75 changes: 63 additions & 12 deletions tests/unit/auth/v4/timeUtils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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', () => {
Expand Down
Loading