alexa-cookie2 5.0.3 → 5.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +3 -0
- package/alexa-cookie.js +80 -17
- package/lib/proxy.js +131 -44
- package/package.json +5 -5
- package/test/base-amazon-page-handle.test.js +123 -0
- package/test/proxy-cookie-rewrite.test.js +148 -0
- package/test/proxy-cookie-sanitizing.test.js +186 -0
- package/test/proxy-host-switch.test.js +166 -0
- package/test/proxy-initial-url.test.js +184 -0
- package/test/proxy-success-requires-auth-code.test.js +170 -0
- package/test/refresh-skips-app-registration.test.js +270 -0
- package/test/registration-hardening.test.js +261 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const vm = require('vm');
|
|
6
|
+
|
|
7
|
+
const testName = 'proxy-success-requires-auth-code';
|
|
8
|
+
const outputDir = process.env.ALEXA_COOKIE_TEST_OUTPUT_DIR || path.join(__dirname, '..', 'test-output');
|
|
9
|
+
const outputFile = path.join(outputDir, `${testName}.txt`);
|
|
10
|
+
const lines = [];
|
|
11
|
+
|
|
12
|
+
function line(value = '') {
|
|
13
|
+
lines.push(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeOutput() {
|
|
17
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
fs.writeFileSync(outputFile, `${lines.join('\n')}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function recordAssertion(description, fn) {
|
|
22
|
+
try {
|
|
23
|
+
fn();
|
|
24
|
+
line(`${description}: PASS`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
line(`${description}: FAIL`);
|
|
27
|
+
writeOutput();
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const proxyFile = path.join(__dirname, '..', 'lib', 'proxy.js');
|
|
33
|
+
const source = fs.readFileSync(proxyFile, 'utf8');
|
|
34
|
+
|
|
35
|
+
let capturedProxyOptions;
|
|
36
|
+
|
|
37
|
+
function createExpressStub() {
|
|
38
|
+
return {
|
|
39
|
+
use() {},
|
|
40
|
+
get() {},
|
|
41
|
+
listen() {
|
|
42
|
+
const server = {
|
|
43
|
+
address: () => ({ port: 3456 }),
|
|
44
|
+
on: () => server
|
|
45
|
+
};
|
|
46
|
+
return server;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadProxyModule() {
|
|
52
|
+
capturedProxyOptions = undefined;
|
|
53
|
+
const module = { exports: {} };
|
|
54
|
+
const sandbox = {
|
|
55
|
+
Buffer,
|
|
56
|
+
URL,
|
|
57
|
+
__dirname: path.dirname(proxyFile),
|
|
58
|
+
console,
|
|
59
|
+
module,
|
|
60
|
+
exports: module.exports,
|
|
61
|
+
require(name) {
|
|
62
|
+
if (name === 'express') return createExpressStub;
|
|
63
|
+
if (name === 'http-proxy-response-rewrite') return () => {};
|
|
64
|
+
if (name === 'http-proxy-middleware') {
|
|
65
|
+
return {
|
|
66
|
+
createProxyMiddleware(_context, options) {
|
|
67
|
+
capturedProxyOptions = options;
|
|
68
|
+
return function proxyMiddleware() {};
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (name === 'cookie') {
|
|
73
|
+
return { parse: () => ({}) };
|
|
74
|
+
}
|
|
75
|
+
return require(name);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
vm.runInNewContext(source, sandbox, { filename: proxyFile });
|
|
79
|
+
return module.exports;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createProxyResponse(location) {
|
|
83
|
+
return {
|
|
84
|
+
statusCode: 302,
|
|
85
|
+
headers: {
|
|
86
|
+
location
|
|
87
|
+
},
|
|
88
|
+
socket: {
|
|
89
|
+
_host: 'www.amazon.de',
|
|
90
|
+
parser: {
|
|
91
|
+
outgoing: {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
path: '/ap/signin',
|
|
94
|
+
getHeader() {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const proxyModule = loadProxyModule();
|
|
104
|
+
const formerDataStorePath = path.join(os.tmpdir(), `alexa-cookie-proxy-success-test-${Date.now()}.json`);
|
|
105
|
+
let callbackData;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const input = {
|
|
109
|
+
proxyOwnIp: '127.0.0.1',
|
|
110
|
+
proxyPort: 3456,
|
|
111
|
+
proxyListenBind: '0.0.0.0',
|
|
112
|
+
baseAmazonPage: 'amazon.de',
|
|
113
|
+
baseAmazonPageHandle: '_de',
|
|
114
|
+
amazonPageProxyLanguage: 'de_DE',
|
|
115
|
+
acceptLanguage: 'de-DE',
|
|
116
|
+
proxyLogLevel: 'silent',
|
|
117
|
+
formerDataStorePath
|
|
118
|
+
};
|
|
119
|
+
proxyModule.initAmazonProxy(input, (_err, data) => {
|
|
120
|
+
callbackData = data;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const responseLocation = 'https://www.amazon.de/ap/maplanding?openid.mode=id_res&openid.return_to=https%3A%2F%2Fwww.amazon.de%2Fap%2Fmaplanding';
|
|
124
|
+
const proxyRes = createProxyResponse(responseLocation);
|
|
125
|
+
const req = {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
url: '/www.amazon.de/ap/signin',
|
|
128
|
+
originalUrl: '/www.amazon.de/ap/signin'
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
capturedProxyOptions.onProxyRes(proxyRes, req, {});
|
|
132
|
+
|
|
133
|
+
line('TEST: proxy success requires auth code');
|
|
134
|
+
line('');
|
|
135
|
+
line('CODE UNDER TEST:');
|
|
136
|
+
line('- lib/proxy.js: onProxyRes()');
|
|
137
|
+
line('- lib/proxy.js: maplanding success detection');
|
|
138
|
+
line('');
|
|
139
|
+
line('INPUT:');
|
|
140
|
+
line(`response location: ${responseLocation}`);
|
|
141
|
+
line(`request method: ${req.method}`);
|
|
142
|
+
line(`request originalUrl: ${req.originalUrl}`);
|
|
143
|
+
line('');
|
|
144
|
+
line('OBSERVED:');
|
|
145
|
+
line(`callback data present: ${callbackData !== undefined}`);
|
|
146
|
+
line(`final response location: ${proxyRes.headers.location}`);
|
|
147
|
+
line('');
|
|
148
|
+
line('ASSERTIONS:');
|
|
149
|
+
recordAssertion('callbackData === undefined', () => {
|
|
150
|
+
assert.strictEqual(callbackData === undefined, true);
|
|
151
|
+
});
|
|
152
|
+
recordAssertion('final location !== local /cookie-success', () => {
|
|
153
|
+
assert.notStrictEqual(proxyRes.headers.location, 'http://127.0.0.1:3456/cookie-success');
|
|
154
|
+
});
|
|
155
|
+
recordAssertion('final location preserves proxied maplanding path', () => {
|
|
156
|
+
assert.strictEqual(proxyRes.headers.location, 'http://127.0.0.1:3456/www.amazon.de/ap/maplanding?openid.mode=id_res&openid.return_to=https%3A%2F%2Fwww.amazon.de%2Fap%2Fmaplanding');
|
|
157
|
+
});
|
|
158
|
+
line('');
|
|
159
|
+
line('RESULT: PASS');
|
|
160
|
+
writeOutput();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!lines.includes('RESULT: PASS')) {
|
|
163
|
+
line('');
|
|
164
|
+
line('RESULT: FAIL');
|
|
165
|
+
writeOutput();
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
} finally {
|
|
169
|
+
fs.rmSync(formerDataStorePath, { force: true });
|
|
170
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const vm = require('vm');
|
|
5
|
+
const EventEmitter = require('events');
|
|
6
|
+
|
|
7
|
+
const testName = 'refresh-skips-app-registration';
|
|
8
|
+
const outputDir = process.env.ALEXA_COOKIE_TEST_OUTPUT_DIR || path.join(__dirname, '..', 'test-output');
|
|
9
|
+
const outputFile = path.join(outputDir, `${testName}.txt`);
|
|
10
|
+
const lines = [];
|
|
11
|
+
|
|
12
|
+
function line(value = '') {
|
|
13
|
+
lines.push(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeOutput() {
|
|
17
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
fs.writeFileSync(outputFile, `${lines.join('\n')}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function recordAssertion(description, fn) {
|
|
22
|
+
try {
|
|
23
|
+
fn();
|
|
24
|
+
line(`${description}: PASS`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
line(`${description}: FAIL`);
|
|
27
|
+
writeOutput();
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseCookies(cookieHeader) {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
35
|
+
const trimmed = part.trim();
|
|
36
|
+
if (!trimmed) continue;
|
|
37
|
+
const idx = trimmed.indexOf('=');
|
|
38
|
+
if (idx === -1) continue;
|
|
39
|
+
result[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cookieFile = path.join(__dirname, '..', 'alexa-cookie.js');
|
|
45
|
+
const source = fs.readFileSync(cookieFile, 'utf8');
|
|
46
|
+
|
|
47
|
+
function loadCookieModule(overrides = {}) {
|
|
48
|
+
const module = { exports: {} };
|
|
49
|
+
const sandbox = {
|
|
50
|
+
Buffer,
|
|
51
|
+
URL,
|
|
52
|
+
__dirname: path.dirname(cookieFile),
|
|
53
|
+
console,
|
|
54
|
+
module,
|
|
55
|
+
exports: module.exports,
|
|
56
|
+
require(name) {
|
|
57
|
+
if (name === 'https' && overrides.https) return overrides.https;
|
|
58
|
+
if (name === './lib/proxy.js') {
|
|
59
|
+
return {
|
|
60
|
+
initAmazonProxy() {
|
|
61
|
+
throw new Error('proxy should not start in this test');
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (name === 'cookie') return { parse: parseCookies };
|
|
66
|
+
return require(name);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
vm.runInNewContext(source, sandbox, { filename: cookieFile });
|
|
70
|
+
return module.exports;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createFakeHttps(calls) {
|
|
74
|
+
function responseFor(options) {
|
|
75
|
+
if (options.path === '/auth/token') {
|
|
76
|
+
return {
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
headers: { 'set-cookie': ['session-id=SID_REFRESH; Path=/; Domain=.amazon.de'] },
|
|
79
|
+
body: JSON.stringify({ access_token: 'ACCESS_TOKEN_AFTER_REFRESH' })
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (options.host === 'api.amazonalexa.com' && options.path === '/v1/devices/@self/capabilities') {
|
|
83
|
+
return { statusCode: 204, headers: {}, body: '' };
|
|
84
|
+
}
|
|
85
|
+
if (options.path === '/ap/exchangetoken/cookies') {
|
|
86
|
+
return {
|
|
87
|
+
statusCode: 200,
|
|
88
|
+
headers: { 'set-cookie': ['exchange-cookie=EXCHANGE_HEADER; Path=/; Domain=.amazon.de'] },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
response: {
|
|
91
|
+
tokens: {
|
|
92
|
+
cookies: {
|
|
93
|
+
'.amazon.de': [
|
|
94
|
+
{ Name: 'session-id', Value: 'SID_LOCAL' },
|
|
95
|
+
{ Name: 'ubid-acbde', Value: 'UBID_LOCAL' }
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (options.path === '/api/language') {
|
|
104
|
+
return {
|
|
105
|
+
statusCode: 200,
|
|
106
|
+
headers: { 'set-cookie': ['csrf=CSRF_FROM_API; Path=/; Domain=.amazon.de'] },
|
|
107
|
+
body: '{}'
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Unexpected request: ${options.method || 'GET'} ${options.host}${options.path}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
request(options, callback) {
|
|
115
|
+
const req = new EventEmitter();
|
|
116
|
+
let requestBody = '';
|
|
117
|
+
req.write = (chunk) => {
|
|
118
|
+
requestBody += chunk;
|
|
119
|
+
};
|
|
120
|
+
req.end = () => {
|
|
121
|
+
calls.push({
|
|
122
|
+
method: options.method || 'GET',
|
|
123
|
+
host: options.host,
|
|
124
|
+
path: options.path,
|
|
125
|
+
status: null,
|
|
126
|
+
hasAuthorization: Boolean(options.headers && options.headers.authorization),
|
|
127
|
+
bodyLength: requestBody.length
|
|
128
|
+
});
|
|
129
|
+
const response = responseFor(options);
|
|
130
|
+
calls[calls.length - 1].status = response.statusCode;
|
|
131
|
+
const res = new EventEmitter();
|
|
132
|
+
res.statusCode = response.statusCode;
|
|
133
|
+
res.headers = response.headers || {};
|
|
134
|
+
res.socket = { end() {} };
|
|
135
|
+
callback(res);
|
|
136
|
+
if (response.body) res.emit('data', Buffer.from(response.body));
|
|
137
|
+
res.emit('end');
|
|
138
|
+
};
|
|
139
|
+
return req;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function refresh(cookieModule, options) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
cookieModule.refreshAlexaCookie(options, (err, data) => {
|
|
147
|
+
if (err) reject(err);
|
|
148
|
+
else resolve(data);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
(async () => {
|
|
154
|
+
const calls = [];
|
|
155
|
+
const cookieModule = loadCookieModule({ https: createFakeHttps(calls) });
|
|
156
|
+
const formerRegistrationData = {
|
|
157
|
+
loginCookie: 'frc=FRC_OLD; map-md=MAPMD_OLD; session-id=SID_OLD',
|
|
158
|
+
localCookie: 'old-local=OLD',
|
|
159
|
+
refreshToken: 'REFRESH_TOKEN_OLD',
|
|
160
|
+
accessToken: 'ACCESS_TOKEN_OLD',
|
|
161
|
+
macDms: 'MAC_DMS_OLD',
|
|
162
|
+
csrf: 'CSRF_OLD',
|
|
163
|
+
amazonPage: 'amazon.de',
|
|
164
|
+
authorization_code: 'AUTH_CODE_FROM_LOGIN',
|
|
165
|
+
verifier: 'VERIFIER_FROM_LOGIN',
|
|
166
|
+
dataVersion: 2
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const result = await refresh(cookieModule, {
|
|
171
|
+
baseAmazonPage: 'amazon.de',
|
|
172
|
+
amazonPage: 'amazon.de',
|
|
173
|
+
acceptLanguage: 'de-DE',
|
|
174
|
+
formerRegistrationData,
|
|
175
|
+
logger: () => {}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const paths = calls.map((call) => call.path);
|
|
179
|
+
const capabilityCall = calls.find((call) => call.path === '/v1/devices/@self/capabilities');
|
|
180
|
+
|
|
181
|
+
line('TEST: refresh skips app registration');
|
|
182
|
+
line('');
|
|
183
|
+
line('CODE UNDER TEST:');
|
|
184
|
+
line('- alexa-cookie.js: refreshAlexaCookie()');
|
|
185
|
+
line('- alexa-cookie.js: registerTokenCapabilities()');
|
|
186
|
+
line('- alexa-cookie.js: getLocalCookies()');
|
|
187
|
+
line('');
|
|
188
|
+
line('INPUT:');
|
|
189
|
+
line('baseAmazonPage: amazon.de');
|
|
190
|
+
line('amazonPage: amazon.de');
|
|
191
|
+
line(`formerRegistrationData has refreshToken: ${Boolean(formerRegistrationData.refreshToken)}`);
|
|
192
|
+
line(`formerRegistrationData has accessToken: ${Boolean(formerRegistrationData.accessToken)}`);
|
|
193
|
+
line(`formerRegistrationData has authorization_code: ${Object.prototype.hasOwnProperty.call(formerRegistrationData, 'authorization_code')}`);
|
|
194
|
+
line(`formerRegistrationData has verifier: ${Object.prototype.hasOwnProperty.call(formerRegistrationData, 'verifier')}`);
|
|
195
|
+
line('');
|
|
196
|
+
line('OBSERVED REQUESTS:');
|
|
197
|
+
calls.forEach((call) => {
|
|
198
|
+
line(`- ${call.method} ${call.host}${call.path} -> ${call.status}`);
|
|
199
|
+
});
|
|
200
|
+
line('');
|
|
201
|
+
line('OBSERVED RESULT:');
|
|
202
|
+
line(`result amazonPage: ${result.amazonPage}`);
|
|
203
|
+
line(`result has accessToken: ${Boolean(result.accessToken)}`);
|
|
204
|
+
line(`result has refreshToken: ${Boolean(result.refreshToken)}`);
|
|
205
|
+
line(`result has macDms: ${Boolean(result.macDms)}`);
|
|
206
|
+
line(`result has csrf: ${Boolean(result.csrf)}`);
|
|
207
|
+
line(`result has authorization_code: ${Object.prototype.hasOwnProperty.call(result, 'authorization_code')}`);
|
|
208
|
+
line(`result has verifier: ${Object.prototype.hasOwnProperty.call(result, 'verifier')}`);
|
|
209
|
+
line(`result loginCookie includes exchange-cookie: ${result.loginCookie.includes('exchange-cookie=EXCHANGE_HEADER')}`);
|
|
210
|
+
line(`result localCookie includes refreshed session-id: ${result.localCookie.includes('session-id=SID_LOCAL')}`);
|
|
211
|
+
line('');
|
|
212
|
+
line('ASSERTIONS:');
|
|
213
|
+
recordAssertion('requests include /auth/token', () => {
|
|
214
|
+
assert.ok(paths.includes('/auth/token'));
|
|
215
|
+
});
|
|
216
|
+
recordAssertion('requests include /v1/devices/@self/capabilities', () => {
|
|
217
|
+
assert.ok(paths.includes('/v1/devices/@self/capabilities'));
|
|
218
|
+
});
|
|
219
|
+
recordAssertion('requests include /ap/exchangetoken/cookies', () => {
|
|
220
|
+
assert.ok(paths.includes('/ap/exchangetoken/cookies'));
|
|
221
|
+
});
|
|
222
|
+
recordAssertion('requests include /api/language', () => {
|
|
223
|
+
assert.ok(paths.includes('/api/language'));
|
|
224
|
+
});
|
|
225
|
+
recordAssertion('requests do not include /auth/register', () => {
|
|
226
|
+
assert.ok(!paths.includes('/auth/register'));
|
|
227
|
+
});
|
|
228
|
+
recordAssertion('capability request uses PUT api.amazonalexa.com', () => {
|
|
229
|
+
assert.strictEqual(capabilityCall.host, 'api.amazonalexa.com');
|
|
230
|
+
assert.strictEqual(capabilityCall.method, 'PUT');
|
|
231
|
+
});
|
|
232
|
+
recordAssertion('capability request has authorization header', () => {
|
|
233
|
+
assert.strictEqual(capabilityCall.hasAuthorization, true);
|
|
234
|
+
});
|
|
235
|
+
recordAssertion('result accessToken was refreshed', () => {
|
|
236
|
+
assert.strictEqual(result.accessToken, 'ACCESS_TOKEN_AFTER_REFRESH');
|
|
237
|
+
});
|
|
238
|
+
recordAssertion('result refreshToken preserved', () => {
|
|
239
|
+
assert.strictEqual(result.refreshToken, 'REFRESH_TOKEN_OLD');
|
|
240
|
+
});
|
|
241
|
+
recordAssertion('result macDms preserved', () => {
|
|
242
|
+
assert.strictEqual(result.macDms, 'MAC_DMS_OLD');
|
|
243
|
+
});
|
|
244
|
+
recordAssertion('result csrf refreshed from API cookie', () => {
|
|
245
|
+
assert.strictEqual(result.csrf, 'CSRF_FROM_API');
|
|
246
|
+
});
|
|
247
|
+
recordAssertion('result localCookie contains exchanged session-id', () => {
|
|
248
|
+
assert.ok(result.localCookie.includes('session-id=SID_LOCAL'));
|
|
249
|
+
});
|
|
250
|
+
recordAssertion('result loginCookie excludes non-Amazon exchange response cookie', () => {
|
|
251
|
+
assert.ok(!result.loginCookie.includes('exchange-cookie=EXCHANGE_HEADER'));
|
|
252
|
+
});
|
|
253
|
+
recordAssertion('result does not keep authorization_code', () => {
|
|
254
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(result, 'authorization_code'), false);
|
|
255
|
+
});
|
|
256
|
+
recordAssertion('result does not keep verifier', () => {
|
|
257
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(result, 'verifier'), false);
|
|
258
|
+
});
|
|
259
|
+
line('');
|
|
260
|
+
line('RESULT: PASS');
|
|
261
|
+
writeOutput();
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (!lines.includes('RESULT: PASS')) {
|
|
264
|
+
line('');
|
|
265
|
+
line('RESULT: FAIL');
|
|
266
|
+
writeOutput();
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const vm = require('vm');
|
|
5
|
+
const EventEmitter = require('events');
|
|
6
|
+
|
|
7
|
+
const testName = 'registration-hardening';
|
|
8
|
+
const outputDir = process.env.ALEXA_COOKIE_TEST_OUTPUT_DIR || path.join(__dirname, '..', 'test-output');
|
|
9
|
+
const outputFile = path.join(outputDir, `${testName}.txt`);
|
|
10
|
+
const lines = [];
|
|
11
|
+
|
|
12
|
+
function line(value = '') {
|
|
13
|
+
lines.push(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeOutput() {
|
|
17
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
fs.writeFileSync(outputFile, `${lines.join('\n')}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function recordAssertion(description, fn) {
|
|
22
|
+
try {
|
|
23
|
+
fn();
|
|
24
|
+
line(`${description}: PASS`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
line(`${description}: FAIL`);
|
|
27
|
+
writeOutput();
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseCookies(cookieHeader) {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
35
|
+
const trimmed = part.trim();
|
|
36
|
+
if (!trimmed) continue;
|
|
37
|
+
const idx = trimmed.indexOf('=');
|
|
38
|
+
if (idx === -1) continue;
|
|
39
|
+
result[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cookieFile = path.join(__dirname, '..', 'alexa-cookie.js');
|
|
45
|
+
const source = fs.readFileSync(cookieFile, 'utf8');
|
|
46
|
+
|
|
47
|
+
function createProxyStub(proxyData) {
|
|
48
|
+
return {
|
|
49
|
+
initAmazonProxy(_options, callbackCookie) {
|
|
50
|
+
callbackCookie(null, Object.assign({
|
|
51
|
+
loginCookie: 'connect.sid=LOCAL_BROWSER; session-id=SID_BROWSER; ubid-acbde=UBID_BROWSER; frc=FRC_PROXY; map-md=MAPMD_PROXY',
|
|
52
|
+
authorization_code: 'AUTH_CODE_FROM_PROXY',
|
|
53
|
+
verifier: 'VERIFIER_FROM_PROXY',
|
|
54
|
+
deviceId: 'DEVICE_ID_FROM_PROXY',
|
|
55
|
+
frc: 'FRC_PROXY',
|
|
56
|
+
'map-md': 'MAPMD_PROXY'
|
|
57
|
+
}, proxyData || {}));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadCookieModule(overrides = {}) {
|
|
63
|
+
const module = { exports: {} };
|
|
64
|
+
const sandbox = {
|
|
65
|
+
Buffer,
|
|
66
|
+
URL,
|
|
67
|
+
__dirname: path.dirname(cookieFile),
|
|
68
|
+
console,
|
|
69
|
+
module,
|
|
70
|
+
exports: module.exports,
|
|
71
|
+
require(name) {
|
|
72
|
+
if (name === 'https') return overrides.https;
|
|
73
|
+
if (name === './lib/proxy.js') return createProxyStub(overrides.proxyData);
|
|
74
|
+
if (name === 'cookie') return { parse: parseCookies };
|
|
75
|
+
return require(name);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
vm.runInNewContext(source, sandbox, { filename: cookieFile });
|
|
79
|
+
return module.exports;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createFakeHttps(config, calls) {
|
|
83
|
+
function responseFor(options) {
|
|
84
|
+
if (options.path === '/auth/register') {
|
|
85
|
+
return {
|
|
86
|
+
statusCode: 200,
|
|
87
|
+
headers: { 'set-cookie': ['session-id=SID_REGISTER; Path=/; Domain=.amazon.de'] },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
response: {
|
|
90
|
+
success: {
|
|
91
|
+
tokens: {
|
|
92
|
+
bearer: {
|
|
93
|
+
refresh_token: 'REFRESH_TOKEN_AFTER_REGISTER',
|
|
94
|
+
access_token: 'ACCESS_TOKEN_AFTER_REGISTER'
|
|
95
|
+
},
|
|
96
|
+
mac_dms: {
|
|
97
|
+
device_private_key: 'PRIVATE_KEY_AFTER_REGISTER',
|
|
98
|
+
adp_token: 'ADP_TOKEN_AFTER_REGISTER'
|
|
99
|
+
},
|
|
100
|
+
website_cookies: [
|
|
101
|
+
{ Name: 'session-token', Value: 'SESSION_TOKEN_AFTER_REGISTER' },
|
|
102
|
+
{ Name: 'at-acbde', Value: 'AT_AFTER_REGISTER' },
|
|
103
|
+
{ Name: 'sess-at-acbde', Value: 'SESS_AT_AFTER_REGISTER' }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (options.host === 'api.amazonalexa.com' && options.path === '/v1/devices/@self/capabilities') {
|
|
112
|
+
return { statusCode: 204, headers: {}, body: '' };
|
|
113
|
+
}
|
|
114
|
+
if (options.path === '/api/users/me?platform=ios&version=2.2.651540.0') {
|
|
115
|
+
if (config.userDataMode === 'empty401') {
|
|
116
|
+
return { statusCode: 401, headers: {}, body: '' };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
statusCode: 200,
|
|
120
|
+
headers: {},
|
|
121
|
+
body: JSON.stringify({ marketPlaceDomainName: 'www.amazon.de' })
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (options.path === '/ap/exchangetoken/cookies') {
|
|
125
|
+
if (config.localCookieMode === 'error') {
|
|
126
|
+
return { statusCode: 200, headers: {}, body: '{}' };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
statusCode: 200,
|
|
130
|
+
headers: { 'set-cookie': ['session-id=SID_LOCAL_HEADER; Path=/; Domain=.amazon.de'] },
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
response: {
|
|
133
|
+
tokens: {
|
|
134
|
+
cookies: {
|
|
135
|
+
'.amazon.de': [
|
|
136
|
+
{ Name: 'session-id', Value: 'SID_LOCAL' },
|
|
137
|
+
{ Name: 'ubid-acbde', Value: 'UBID_LOCAL' }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (options.path === '/api/language') {
|
|
146
|
+
return {
|
|
147
|
+
statusCode: 200,
|
|
148
|
+
headers: { 'set-cookie': ['csrf=CSRF_FROM_API; Path=/; Domain=.amazon.de'] },
|
|
149
|
+
body: '{}'
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Unexpected request: ${options.method || 'GET'} ${options.host}${options.path}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
request(options, callback) {
|
|
157
|
+
const req = new EventEmitter();
|
|
158
|
+
let requestBody = '';
|
|
159
|
+
req.write = (chunk) => {
|
|
160
|
+
requestBody += chunk;
|
|
161
|
+
};
|
|
162
|
+
req.end = () => {
|
|
163
|
+
const call = {
|
|
164
|
+
method: options.method || 'GET',
|
|
165
|
+
host: options.host,
|
|
166
|
+
path: options.path,
|
|
167
|
+
headers: Object.assign({}, options.headers),
|
|
168
|
+
requestBody,
|
|
169
|
+
status: null
|
|
170
|
+
};
|
|
171
|
+
calls.push(call);
|
|
172
|
+
const response = responseFor(options);
|
|
173
|
+
call.status = response.statusCode;
|
|
174
|
+
const res = new EventEmitter();
|
|
175
|
+
res.statusCode = response.statusCode;
|
|
176
|
+
res.headers = response.headers || {};
|
|
177
|
+
res.socket = { end() {} };
|
|
178
|
+
callback(res);
|
|
179
|
+
if (response.body) res.emit('data', Buffer.from(response.body));
|
|
180
|
+
res.emit('end');
|
|
181
|
+
};
|
|
182
|
+
return req;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function runScenario(config = {}) {
|
|
188
|
+
const calls = [];
|
|
189
|
+
const callbacks = [];
|
|
190
|
+
const cookieModule = loadCookieModule({
|
|
191
|
+
https: createFakeHttps(config, calls),
|
|
192
|
+
proxyData: config.proxyData
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
cookieModule.generateAlexaCookie({
|
|
196
|
+
proxyOwnIp: '127.0.0.1',
|
|
197
|
+
proxyPort: 3456,
|
|
198
|
+
proxyListenBind: '0.0.0.0',
|
|
199
|
+
baseAmazonPage: 'amazon.de',
|
|
200
|
+
amazonPage: 'amazon.de',
|
|
201
|
+
acceptLanguage: 'de-DE',
|
|
202
|
+
proxyLogLevel: 'silent',
|
|
203
|
+
logger: () => {}
|
|
204
|
+
}, (err, data) => {
|
|
205
|
+
callbacks.push({ err, data });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { calls, callbacks };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
line('TEST: registration hardening');
|
|
213
|
+
line('');
|
|
214
|
+
line('CODE UNDER TEST:');
|
|
215
|
+
line('- alexa-cookie.js: handleTokenRegistration()');
|
|
216
|
+
line('- alexa-cookie.js: optional /api/users/me handling');
|
|
217
|
+
line('- alexa-cookie.js: getLocalCookies() error path after registration');
|
|
218
|
+
line('');
|
|
219
|
+
|
|
220
|
+
const sanitizedScenario = runScenario();
|
|
221
|
+
const registerCall = sanitizedScenario.calls.find((call) => call.path === '/auth/register');
|
|
222
|
+
const registerBody = JSON.parse(registerCall.requestBody);
|
|
223
|
+
|
|
224
|
+
line('ASSERTIONS:');
|
|
225
|
+
recordAssertion('registration Cookie header removes unrelated local browser cookie', () => {
|
|
226
|
+
assert.ok(!registerCall.headers.Cookie.includes('connect.sid=LOCAL_BROWSER'));
|
|
227
|
+
});
|
|
228
|
+
recordAssertion('registration body removes unrelated local browser cookie', () => {
|
|
229
|
+
assert.ok(!registerBody.cookies.website_cookies.some((cookie) => cookie.Name === 'connect.sid'));
|
|
230
|
+
});
|
|
231
|
+
recordAssertion('registration body keeps Amazon session cookie', () => {
|
|
232
|
+
assert.ok(registerBody.cookies.website_cookies.some((cookie) => cookie.Name === 'session-id' && cookie.Value === 'SID_BROWSER'));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const emptyUserDataScenario = runScenario({ userDataMode: 'empty401' });
|
|
236
|
+
recordAssertion('empty optional user-data response continues when amazonPage is known', () => {
|
|
237
|
+
assert.strictEqual(emptyUserDataScenario.callbacks.length, 1);
|
|
238
|
+
assert.ifError(emptyUserDataScenario.callbacks[0].err);
|
|
239
|
+
assert.strictEqual(emptyUserDataScenario.callbacks[0].data.amazonPage, 'amazon.de');
|
|
240
|
+
assert.ok(emptyUserDataScenario.callbacks[0].data.localCookie.includes('session-id=SID_LOCAL'));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const localCookieErrorScenario = runScenario({ localCookieMode: 'error' });
|
|
244
|
+
recordAssertion('local-cookie retrieval error stops registration callback flow', () => {
|
|
245
|
+
assert.strictEqual(localCookieErrorScenario.callbacks.length, 1);
|
|
246
|
+
assert.ok(localCookieErrorScenario.callbacks[0].err);
|
|
247
|
+
assert.match(localCookieErrorScenario.callbacks[0].err.message, /No cookies in Exchange response/);
|
|
248
|
+
assert.strictEqual(localCookieErrorScenario.calls.filter((call) => call.path === '/api/language').length, 0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
line('');
|
|
252
|
+
line('RESULT: PASS');
|
|
253
|
+
writeOutput();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (!lines.includes('RESULT: PASS')) {
|
|
256
|
+
line('');
|
|
257
|
+
line('RESULT: FAIL');
|
|
258
|
+
writeOutput();
|
|
259
|
+
}
|
|
260
|
+
throw err;
|
|
261
|
+
}
|