Skip to content

Commit ded10b1

Browse files
committed
Merge remote-tracking branch 'origin/bugfix/ARSN-560-error-with-date-before-epoch' into w/8.3/bugfix/ARSN-560-error-with-date-before-epoch
2 parents 22e8dbc + 362f790 commit ded10b1

File tree

5 files changed

+102
-77
lines changed

5 files changed

+102
-77
lines changed

lib/auth/v4/headerAuthCheck.ts

Lines changed: 33 additions & 22 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,
@@ -80,33 +79,46 @@ export function check(
8079
return { err: errors.AccessDenied };
8180
}
8281

83-
let timestamp: string | undefined;
82+
let timestampCompact: string | undefined;
83+
let timestampParsed: Date | undefined;
84+
8485
// check request timestamp
8586
const xAmzDate = request.headers['x-amz-date'];
8687
if (xAmzDate) {
87-
if (isValidISO8601Compact(xAmzDate)) {
88-
timestamp = xAmzDate;
88+
const parsed = parseISO8601Compact(xAmzDate);
89+
if (parsed) {
90+
timestampCompact = xAmzDate;
91+
timestampParsed = parsed;
8992
}
9093
} else if (request.headers.date) {
91-
if (isValidISO8601Compact(request.headers.date)) {
92-
timestamp = request.headers.date;
94+
const parsed = parseISO8601Compact(request.headers.date);
95+
if (parsed) {
96+
timestampCompact = request.headers.date;
97+
timestampParsed = parsed;
9398
} else {
94-
timestamp = convertUTCtoISO8601(request.headers.date);
99+
const converted = convertUTCtoISO8601(request.headers.date);
100+
if (converted) {
101+
const convertedParsed = parseISO8601Compact(converted);
102+
if (convertedParsed) {
103+
timestampCompact = converted;
104+
timestampParsed = convertedParsed;
105+
}
106+
}
95107
}
96108
}
97-
if (!timestamp) {
109+
const beforeEpoch = timestampParsed && timestampParsed.getTime() < 0;
110+
if (!timestampCompact || !timestampParsed || beforeEpoch) {
98111
log.debug('missing or invalid date header',
99112
{ 'method': 'auth/v4/headerAuthCheck.check', 'x-amz-date': xAmzDate, 'Date': request.headers.date });
100-
return { err: errorInstances.AccessDenied.
101-
customizeDescription('Authentication requires a valid Date or ' +
102-
'x-amz-date header') };
113+
return {
114+
err: errorInstances.AccessDenied.
115+
customizeDescription('Authentication requires a valid Date or x-amz-date header')
116+
};
103117
}
104118

105-
const validationResult = validateCredentials(credentialsArr, timestamp,
106-
log);
119+
const validationResult = validateCredentials(credentialsArr, timestampCompact, log);
107120
if (validationResult instanceof ArsenalError) {
108-
log.debug('credentials in improper format', { credentialsArr,
109-
timestamp, validationResult });
121+
log.debug('credentials in improper format', { credentialsArr, timestamp: timestampCompact, validationResult });
110122
return { err: validationResult };
111123
}
112124
// credentialsArr is [accessKey, date, region, aws-service, aws4_request]
@@ -128,7 +140,7 @@ export function check(
128140
// note that expiration can be shortened so
129141
// expiry is as set out in the policy.
130142

131-
const isTimeSkewed = checkTimeSkew(timestamp, constants.requestExpirySeconds, log);
143+
const isTimeSkewed = checkTimeSkew(timestampCompact, constants.requestExpirySeconds, log);
132144
if (isTimeSkewed) {
133145
return { err: errors.RequestTimeTooSkewed };
134146
}
@@ -139,8 +151,7 @@ export function check(
139151
proxyPath = decodeURIComponent(request.headers.proxy_path);
140152
} catch (err) {
141153
log.debug('invalid proxy_path header', { proxyPath, err });
142-
return { err: errorInstances.InvalidArgument.customizeDescription(
143-
'invalid proxy_path header') };
154+
return { err: errorInstances.InvalidArgument.customizeDescription('invalid proxy_path header') };
144155
}
145156
}
146157

@@ -150,7 +161,7 @@ export function check(
150161
query: data,
151162
signedHeaders,
152163
credentialScope,
153-
timestamp,
164+
timestamp: timestampCompact,
154165
payloadChecksum,
155166
awsService: service,
156167
proxyPath,
@@ -171,11 +182,11 @@ export function check(
171182
stringToSign,
172183
authType: 'REST-HEADER',
173184
signatureVersion: 'AWS4-HMAC-SHA256',
174-
signatureAge: Date.now() - convertAmzTimeToMs(timestamp),
185+
signatureAge: Date.now() - timestampParsed.getTime(),
175186
// credentialScope and timestamp needed for streaming V4
176187
// chunk evaluation
177188
credentialScope,
178-
timestamp,
189+
timestamp: timestampCompact,
179190
securityToken: token,
180191
},
181192
},

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;
@@ -114,10 +108,19 @@ export function isValidISO8601Compact(str: string): boolean {
114108
const date = new Date(isoString);
115109

116110
try {
111+
if (Number.isNaN(date.getTime())) {
112+
return undefined;
113+
}
114+
117115
// date.toISOString() can throw.
118116
// date.toISOString() === isoString check prevents silent date corrections (30 February to 1 March)
119-
return !Number.isNaN(date.getTime()) && date.toISOString() === isoString;
117+
if (date.toISOString() !== isoString) {
118+
return undefined;
119+
}
120+
121+
return date;
120122
} catch {
121-
return false;
123+
return undefined;
122124
}
123125
}
126+

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"engines": {
44
"node": ">=20"
55
},
6-
"version": "8.3.7",
6+
"version": "8.3.8",
77
"description": "Common utilities for the S3 project components",
88
"main": "build/index.js",
99
"repository": {

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)