Skip to content

Commit fb33c2f

Browse files
committed
ARSN-560: check Date or x-amz-date header is after epoch and replace isValidISO8601Compact with ParseISO8601Compact
1 parent 4be7ad2 commit fb33c2f

File tree

4 files changed

+101
-78
lines changed

4 files changed

+101
-78
lines changed

lib/auth/v4/headerAuthCheck.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import constructStringToSign from './constructStringToSign';
55
import {
66
checkTimeSkew,
77
convertUTCtoISO8601,
8-
convertAmzTimeToMs,
9-
isValidISO8601Compact,
8+
ParseISO8601Compact,
109
} from './timeUtils';
1110
import {
1211
extractAuthItems,
@@ -79,33 +78,46 @@ export function check(
7978
return { err: errors.AccessDenied };
8079
}
8180

82-
let timestamp: string | undefined;
81+
let timestampCompact: string | undefined;
82+
let timestampParsed: Date | undefined;
83+
8384
// check request timestamp
8485
const xAmzDate = request.headers['x-amz-date'];
8586
if (xAmzDate) {
86-
if (isValidISO8601Compact(xAmzDate)) {
87-
timestamp = xAmzDate;
87+
const parsed = ParseISO8601Compact(xAmzDate);
88+
if (parsed) {
89+
timestampCompact = xAmzDate;
90+
timestampParsed = parsed;
8891
}
8992
} else if (request.headers.date) {
90-
if (isValidISO8601Compact(request.headers.date)) {
91-
timestamp = request.headers.date;
93+
const parsed = ParseISO8601Compact(request.headers.date);
94+
if (parsed) {
95+
timestampCompact = request.headers.date;
96+
timestampParsed = parsed;
9297
} else {
93-
timestamp = convertUTCtoISO8601(request.headers.date);
98+
const converted = convertUTCtoISO8601(request.headers.date);
99+
if (converted) {
100+
const convertedParsed = ParseISO8601Compact(converted);
101+
if (convertedParsed) {
102+
timestampCompact = converted;
103+
timestampParsed = convertedParsed;
104+
}
105+
}
94106
}
95107
}
96-
if (!timestamp) {
108+
const beforeEpoch = timestampParsed && timestampParsed.getTime() < 0;
109+
if (!timestampCompact || !timestampParsed || beforeEpoch) {
97110
log.debug('missing or invalid date header',
98111
{ 'method': 'auth/v4/headerAuthCheck.check', 'x-amz-date': xAmzDate, 'Date': request.headers.date });
99-
return { err: errorInstances.AccessDenied.
100-
customizeDescription('Authentication requires a valid Date or ' +
101-
'x-amz-date header') };
112+
return {
113+
err: errorInstances.AccessDenied.
114+
customizeDescription('Authentication requires a valid Date or ' + 'x-amz-date header')
115+
};
102116
}
103117

104-
const validationResult = validateCredentials(credentialsArr, timestamp,
105-
log);
118+
const validationResult = validateCredentials(credentialsArr, timestampCompact, log);
106119
if (validationResult instanceof ArsenalError) {
107-
log.debug('credentials in improper format', { credentialsArr,
108-
timestamp, validationResult });
120+
log.debug('credentials in improper format', { credentialsArr, timestamp: timestampCompact, validationResult });
109121
return { err: validationResult };
110122
}
111123
// credentialsArr is [accessKey, date, region, aws-service, aws4_request]
@@ -127,9 +139,7 @@ export function check(
127139
// note that expiration can be shortened so
128140
// expiry is as set out in the policy.
129141

130-
// 15 minutes in seconds
131-
const expiry = (15 * 60);
132-
const isTimeSkewed = checkTimeSkew(timestamp, expiry, log);
142+
const isTimeSkewed = checkTimeSkew(timestampCompact, constants.requestExpirySeconds, log);
133143
if (isTimeSkewed) {
134144
return { err: errors.RequestTimeTooSkewed };
135145
}
@@ -140,8 +150,7 @@ export function check(
140150
proxyPath = decodeURIComponent(request.headers.proxy_path);
141151
} catch (err) {
142152
log.debug('invalid proxy_path header', { proxyPath, err });
143-
return { err: errorInstances.InvalidArgument.customizeDescription(
144-
'invalid proxy_path header') };
153+
return { err: errorInstances.InvalidArgument.customizeDescription('invalid proxy_path header') };
145154
}
146155
}
147156

@@ -151,7 +160,7 @@ export function check(
151160
query: data,
152161
signedHeaders,
153162
credentialScope,
154-
timestamp,
163+
timestamp: timestampCompact,
155164
payloadChecksum,
156165
awsService: service,
157166
proxyPath,
@@ -172,11 +181,11 @@ export function check(
172181
stringToSign,
173182
authType: 'REST-HEADER',
174183
signatureVersion: 'AWS4-HMAC-SHA256',
175-
signatureAge: Date.now() - convertAmzTimeToMs(timestamp),
184+
signatureAge: Date.now() - timestampParsed.getTime(),
176185
// credentialScope and timestamp needed for streaming V4
177186
// chunk evaluation
178187
credentialScope,
179-
timestamp,
188+
timestamp: timestampCompact,
180189
securityToken: token,
181190
},
182191
},

lib/auth/v4/timeUtils.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,36 +75,30 @@ export function checkTimeSkew(timestamp: string, expiry: number, log: RequestLog
7575
return false;
7676
}
7777

78+
7879
/**
79-
* Validates if a string is in ISO 8601 compact format: YYYYMMDDTHHMMSSZ
80-
*
81-
* Checks that:
82-
* - String is exactly 16 characters long
83-
* - Format matches YYYYMMDDTHHMMSSZ (8 digits, 'T', 6 digits, 'Z')
84-
* - All date/time components are valid (no Feb 30th, no 25:00:00, etc.)
85-
* - No silent date corrections occur (prevents rollover)
86-
*
87-
* @param str - The string to validate
88-
* @returns true if the string is a valid ISO 8601 compact format, false otherwise
89-
*
90-
* @example
91-
* ```typescript
92-
* isValidISO8601Compact('20160208T201405Z'); // true
93-
* isValidISO8601Compact('20160230T201405Z'); // false (Feb 30 invalid)
94-
* isValidISO8601Compact('20160208T251405Z'); // false (25 hours invalid)
95-
* isValidISO8601Compact('2016-02-08T20:14:05Z'); // false (wrong format)
96-
* isValidISO8601Compact('abcd0208T201405Z'); // false (contains letters)
97-
* ```
98-
*/
99-
export function isValidISO8601Compact(str: string): boolean {
80+
* Parses an ISO 8601 compact timestamp string into a Date object.
81+
*
82+
* @param str - The string to parse
83+
* @returns A Date object if the string is a valid ISO 8601 compact timestamp, undefined otherwise
84+
*
85+
* @example
86+
* ```typescript
87+
* ParseISO8601Compact('20160208T201405Z'); // Date object
88+
* ParseISO8601Compact('19500707T215304Z'); // Date object (pre-Unix epoch)
89+
* ParseISO8601Compact('20160230T201405Z'); // undefined (Feb 30 invalid)
90+
* ParseISO8601Compact('invalid'); // undefined
91+
* ```
92+
*/
93+
export function ParseISO8601Compact(str: string): Date | undefined {
10094
if (typeof str !== 'string') {
101-
return false;
95+
return undefined;
10296
}
10397

10498
// Match format: YYYYMMDDTHHMMSSZ
10599
const match = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
106100
if (!match) {
107-
return false;
101+
return undefined;
108102
}
109103

110104
const [, year, month, day, hour, minute, second] = match;
@@ -116,8 +110,17 @@ export function isValidISO8601Compact(str: string): boolean {
116110
try {
117111
// date.toISOString() can throw.
118112
// date.toISOString() === isoString check prevents silent date corrections (30 February to 1 March)
119-
return !Number.isNaN(date.getTime()) && date.toISOString() === isoString;
113+
if (date.toISOString() !== isoString) {
114+
return undefined;
115+
}
116+
117+
if (Number.isNaN(date.getTime())) {
118+
return undefined;
119+
}
120+
121+
return date;
120122
} catch {
121-
return false;
123+
return undefined;
122124
}
123125
}
126+

tests/unit/auth/v4/headerAuthCheck.spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ describe('v4 headerAuthCheck', () => {
177177
done();
178178
});
179179

180-
it('should return error if timestamp from x-amz-date header' +
180+
it('should return AccessDenied if timestamp from x-amz-date header' +
181181
'is before epochTime', done => {
182182
// Date from 1950 (before epoch time)
183183
const alteredRequest = createAlteredRequest({
@@ -189,7 +189,9 @@ describe('v4 headerAuthCheck', () => {
189189
'0064d22eacd6ccb85c06befa15f' +
190190
'4a789b0bae19307bc' }, 'headers', request, headers);
191191
const res = headerAuthCheck(alteredRequest, log);
192-
assert.deepStrictEqual(res.err, errors.RequestTimeTooSkewed);
192+
assert.deepStrictEqual(res.err, errorInstances.AccessDenied.
193+
customizeDescription('Authentication requires a valid Date or ' +
194+
'x-amz-date header'));
193195
done();
194196
});
195197

tests/unit/auth/v4/timeUtils.spec.js

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const {
77
checkTimeSkew,
88
convertAmzTimeToMs,
99
convertUTCtoISO8601,
10-
isValidISO8601Compact,
10+
ParseISO8601Compact,
1111
} = require('../../../../lib/auth/v4/timeUtils');
1212

1313
const DummyRequestLogger = require('../../helpers').DummyRequestLogger;
@@ -53,34 +53,43 @@ describe('convertUTCtoISO8601 function', () => {
5353
}));
5454
});
5555

56-
describe('isValidISO8601Compact function', () => {
56+
describe('ParseISO8601Compact function', () => {
5757
[
58-
{ name: 'should return true for valid ISO8601 compact format', input: '20160208T201405Z', expected: true },
59-
{ name: 'should return true for valid timestamp with zeros', input: '20200101T000000Z', expected: true },
60-
{ name: 'should return true for valid timestamp at end of day', input: '20201231T235959Z', expected: true },
61-
{ name: 'should return true for leap year Feb 29', input: '20200229T120000Z', expected: true },
62-
{ name: 'should return false for string with wrong length', input: '2016020T201405Z', expected: false },
63-
{ name: 'should return false for ISO8601 with dashes/colons', input: '2016-02-08T20:14:05Z', expected: false },
64-
{ name: 'should return false for missing T separator', input: '20160208 201405Z', expected: false },
65-
{ name: 'should return false for missing Z suffix', input: '20160208T201405', expected: false },
66-
{ name: 'should return false for string with letters in date', input: 'abcd0208T201405Z', expected: false },
67-
{ name: 'should return false for empty string', input: '', expected: false },
68-
{ name: 'should return false for invalid month (13)', input: '20161308T201405Z', expected: false },
69-
{ name: 'should return false for invalid day (32)', input: '20160232T201405Z', expected: false },
70-
{ name: 'should return false for Feb 30 (invalid)', input: '20160230T201405Z', expected: false },
71-
{ name: 'should return false for Feb 29 in non-leap year', input: '20190229T201405Z', expected: false },
72-
{ name: 'should return false for invalid hour (25)', input: '20160208T251405Z', expected: false },
73-
{ name: 'should return false for invalid minute (60)', input: '20160208T206005Z', expected: false },
74-
{ name: 'should return false for invalid second (60)', input: '20160208T201460Z', expected: false },
75-
{ name: 'should return false for month 00', input: '20160008T201405Z', expected: false },
76-
{ name: 'should return false for day 00', input: '20160200T201405Z', expected: false },
77-
{ name: 'should return false for null', input: null, expected: false },
78-
{ name: 'should return false for undefined', input: undefined, expected: false },
79-
{ name: 'should return false for number', input: 20160208201405, expected: false },
80-
{ name: 'should return false for object', input: {}, expected: false },
81-
{ name: 'should return false for array', input: [], expected: false },
58+
{ name: 'should return a Date for valid ISO8601 compact format', input: '20160208T201405Z', expected: new Date('2016-02-08T20:14:05Z') },
59+
{ name: 'should return a Date for valid timestamp with zeros', input: '20200101T000000Z', expected: new Date('2020-01-01T00:00:00Z') },
60+
{ name: 'should return a Date for valid timestamp at end of day', input: '20201231T235959Z', expected: new Date('2020-12-31T23:59:59Z') },
61+
{ name: 'should return a Date for leap year Feb 29', input: '20200229T120000Z', expected: new Date('2020-02-29T12:00:00Z') },
62+
{ name: 'should return a Date for pre-epoch date (1950)', input: '19500707T215304Z', expected: new Date('1950-07-07T21:53:04Z') },
63+
{ name: 'should return a Date for pre-epoch date (1969)', input: '19691231T235959Z', expected: new Date('1969-12-31T23:59:59Z') },
64+
{ name: 'should return a Date for Unix epoch start (1970)', input: '19700101T000000Z', expected: new Date('1970-01-01T00:00:00Z') },
65+
{ name: 'should return undefined for string with wrong length', input: '2016020T201405Z', expected: undefined },
66+
{ name: 'should return undefined for ISO8601 with dashes/colons', input: '2016-02-08T20:14:05Z', expected: undefined },
67+
{ name: 'should return undefined for missing T separator', input: '20160208 201405Z', expected: undefined },
68+
{ name: 'should return undefined for missing Z suffix', input: '20160208T201405', expected: undefined },
69+
{ name: 'should return undefined for string with letters in date', input: 'abcd0208T201405Z', expected: undefined },
70+
{ name: 'should return undefined for empty string', input: '', expected: undefined },
71+
{ name: 'should return undefined for invalid month (13)', input: '20161308T201405Z', expected: undefined },
72+
{ name: 'should return undefined for invalid day (32)', input: '20160232T201405Z', expected: undefined },
73+
{ name: 'should return undefined for Feb 30 (invalid)', input: '20160230T201405Z', expected: undefined },
74+
{ name: 'should return undefined for Feb 29 in non-leap year', input: '20190229T201405Z', expected: undefined },
75+
{ name: 'should return undefined for invalid hour (25)', input: '20160208T251405Z', expected: undefined },
76+
{ name: 'should return undefined for invalid minute (60)', input: '20160208T206005Z', expected: undefined },
77+
{ name: 'should return undefined for invalid second (60)', input: '20160208T201460Z', expected: undefined },
78+
{ name: 'should return undefined for month 00', input: '20160008T201405Z', expected: undefined },
79+
{ name: 'should return undefined for day 00', input: '20160200T201405Z', expected: undefined },
80+
{ name: 'should return undefined for null', input: null, expected: undefined },
81+
{ name: 'should return undefined for undefined', input: undefined, expected: undefined },
82+
{ name: 'should return undefined for number', input: 20160208201405, expected: undefined },
83+
{ name: 'should return undefined for object', input: {}, expected: undefined },
84+
{ name: 'should return undefined for array', input: [], expected: undefined },
8285
].forEach(t => it(t.name, () => {
83-
assert.strictEqual(isValidISO8601Compact(t.input), t.expected);
86+
const result = ParseISO8601Compact(t.input);
87+
if (t.expected instanceof Date) {
88+
assert.ok(result instanceof Date, `expected Date, got ${result}`);
89+
assert.strictEqual(result.getTime(), t.expected.getTime());
90+
} else {
91+
assert.strictEqual(result, undefined);
92+
}
8493
}));
8594
});
8695

0 commit comments

Comments
 (0)