Skip to content
Merged
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
55 changes: 33 additions & 22 deletions lib/auth/v4/headerAuthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import constructStringToSign from './constructStringToSign';
import {
checkTimeSkew,
convertUTCtoISO8601,
convertAmzTimeToMs,
isValidISO8601Compact,
parseISO8601Compact,
} from './timeUtils';
import {
extractAuthItems,
Expand Down Expand Up @@ -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]
Expand All @@ -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 };
}
Expand All @@ -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') };
}
}

Expand All @@ -150,7 +161,7 @@ export function check(
query: data,
signedHeaders,
credentialScope,
timestamp,
timestamp: timestampCompact,
payloadChecksum,
awsService: service,
proxyPath,
Expand All @@ -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,
},
},
Expand Down
53 changes: 28 additions & 25 deletions lib/auth/v4/timeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}

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.3.7",
"version": "8.3.8",
"description": "Common utilities for the S3 project components",
"main": "build/index.js",
"repository": {
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/auth/v4/headerAuthCheck.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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();
});

Expand Down
63 changes: 36 additions & 27 deletions tests/unit/auth/v4/timeUtils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const {
checkTimeSkew,
convertAmzTimeToMs,
convertUTCtoISO8601,
isValidISO8601Compact,
parseISO8601Compact,
} = require('../../../../lib/auth/v4/timeUtils');

const DummyRequestLogger = require('../../helpers').DummyRequestLogger;
Expand Down Expand Up @@ -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);
}
}));
});

Expand Down
Loading