-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathcommon.js
410 lines (367 loc) · 13.6 KB
/
common.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
/*
* Copyright (c) 2019, Psiphon Inc.
* All rights reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import * as consts from './consts.js';
import * as utils from './utils.js';
/**
* The query or hash param key for tokens, metadata, etc. passed by the app into the
* landing page, and the page script into the iframe.
* @const {string}
*/
export const PSICASH_URL_PARAM = 'psicash';
export const DEBUG_URL_PARAM = 'debug';
export const DEV_URL_PARAM = 'dev';
/**
* PsiCashParams captures the intial configuration state of the widget. It is shared
* between the page and iframe.
*/
export class PsiCashParams {
constructor(timestamp, tokens, metadata, dev, debug) {
/**
* Timestamp of when the package was created; used to prioritize which tokens should be used.
* This will be undefined for params from older clients or stored previously.
* @type {string}
*/
this.timestamp = timestamp;
/** @type {string} */
this.tokens = tokens;
/**
* This is information that is included in requests to the PsiCash server, provided by
* the app.
* @type {Object}
*/
this.metadata = metadata;
/** @type {any} */
this.dev = dev;
/** @type {any} */
this.debug = debug;
}
/**
* Create a new PsiCashParams instance from obj. Returns null if obj is falsy.
* @param {?Object} obj
*/
static fromObject(obj) {
if (!obj) {
return null;
}
const { timestamp, tokens, metadata, dev, debug } = obj;
return new PsiCashParams(timestamp, tokens, metadata, dev, debug);
}
/**
* Create a new PsiCashParams instance from an encoded payload string.
* Returns null if `payloadStr` is falsy, a parse error occurs, or the timestamp is unacceptable.
* @param {String} payloadStr
*/
static fromURLPayload(payloadStr) {
if (!payloadStr) {
return null;
}
// The params payload is transferred as URL-encoded JSON (possibly base64).
try {
payloadStr = window.atob(payloadStr);
}
catch (error) {
// Do nothing -- not base64
}
let payloadObj;
try {
payloadObj = JSON.parse(payloadStr);
}
catch (error) {
// Don't let this just throw. We want this function to return in a controlled
// manner, in case there are checks to be done higher up the stack.
utils.log('PsiCashParams.fromURLPayload: JSON.parse failed', error);
return null;
}
const urlParams = PsiCashParams.fromObject(payloadObj);
if (!urlParams) {
utils.log('PsiCashParams.fromURLPayload: fromObject returned null');
return null;
}
// Older clients don't include a timestamp in the params, but if there is one present
// we have to make sure it's valid. To prevent stored-tokens-poisoning attacks
// (https://github.com/Psiphon-Inc/psiphon-issues/issues/555), we don't want to accept
// any URL params that have a timestamp too far in the past (because it should have
// just been generated, not a link in an email or something), nor in the future (we
// don't want bad tokens to get stored forever and never be flushed out by good
// tokens).
if (urlParams.timestamp) {
const nowTimestamp = new Date().getTime();
const urlTimestamp = Date.parse(urlParams.timestamp);
if (isNaN(urlTimestamp)) {
utils.log('PsiCashParams.fromURLPayload: URL params timestamp cannot be parsed');
return null;
}
if (urlTimestamp > nowTimestamp) {
utils.log('PsiCashParams.fromURLPayload: URL params timestamp is in the future');
return null;
}
// We need to give enough time for the app to send the URL to the browser (fast),
// the browser to load the page and start processing JS (slow), and the page to send
// load the iframe (slow-ish). If we don't give enough time for that, then the user
// can't do anything with their tokens. But if we leave _too much_ time, then an
// attacker has too big a window to poison a user's tokens.
if ((nowTimestamp - urlTimestamp) > 60000) {
utils.log('PsiCashParams.fromURLPayload: URL params timestamp is too old');
return null;
}
}
return urlParams;
}
/**
* Returns the base64-encoded JSON of this object.
* @returns {!String}
*/
encode() {
return window.btoa(JSON.stringify(this)).replace(/=+$/, '');
}
/**
* Checks if the properties of cmp are equal to this object's.
* @param {PsiCashParams} cmp
* @returns {boolean}
*/
equal(cmp) {
return JSON.stringify(this) === JSON.stringify(cmp);
}
/**
* Compares `urlParams` and `localParams` and returns the object that has the newest tokens.
* Comparing timestamps is preferred, otherwise `urlParams` takes priority (as it's
* more likely to be new than the locally-stored params).
* Returns null if both are null.
* @param {?PsiCashParams} urlParams
* @param {?PsiCashParams} localParams
* @returns {?PsiCashParams}
*/
static newest(urlParams, localParams) {
if (!urlParams || !localParams) {
// May return null
return urlParams || localParams;
}
else if (urlParams.timestamp && localParams.timestamp) {
// Both param sets have timestamps, so we can compare based on them
return (urlParams.timestamp > localParams.timestamp) ? urlParams : localParams;
}
else if (!!urlParams.timestamp !== !!localParams.timestamp) {
// One of the params has a timestamp and one doesn't; the one with the timestamp
// comes from a more recent client version and is considered newer.
// The null-ness of the tokens doesn't matter in this case. A possible scenario is
// that there are old timestampless params in widget storage and new timestamped
// params in the URL, with no tokens because the client is logged out. We still want
// to prefer the URL params in this case, as they're newer and the the timestampless
// params probably belong to a tracker that has been merged.
return urlParams.timestamp ? urlParams : localParams;
}
else if (!!urlParams.tokens !== !!localParams.tokens) {
// If neither has a timestamp, and one has tokens and the other doesn't, use the tokens.
return urlParams.tokens ? urlParams : localParams;
}
// Neither have timestampns, and either both or neither have tokens. Prefer the URL params.
return urlParams;
}
}
/**
* Defines the structure of messages passed/posted between the page and iframe scripts.
*/
export class Message {
/**
* Constructs a Message.
* @param {string} type
* @param {number} timeout
* @param {any} payload
* @param {string} error
* @param {boolean} success
* @param {string} detail
*/
constructor(type, timeout=null, payload=null, error=null, success=true, detail='') {
/** @type {string} */
this.id = String(Math.random());
/** @type {string} */
this.type = type;
/**
* The amount of time allowed for an action. May not be applicable to all messages.
* @type {number}
*/
this.timeout = timeout;
/** @type {any} */
this.payload = payload;
/**
* If this is set, an unrecoverable error has occurred.
* @type {string}
* */
this.error = error;
/**
* Valid only in the iframe->page direction. Indicates if a requested action was successful.
* @type {boolean}
*/
this.success = success;
/**
* Additional detail about the success or failure of message processing.
* @type {string}
*/
this.detail = detail;
}
/**
* Set the success information. Detail is optional; if not supplied, `this.detail` will
* not be modified.
* @param {boolean} success
* @param {?string} detail Optional
*/
setSuccess(success, detail=undefined) {
this.success = success;
if (typeof detail !== 'undefined') {
this.detail = detail;
}
}
/**
* Create a Message object from the object in a JSON string.
* @param {string} jsonString
* @returns {?Message} Returns null if jsonString is null or empty.
*/
static fromJSON(jsonString) {
if (!jsonString) {
return null;
}
let j = JSON.parse(jsonString);
let m = new Message(j.type, j.timeout, j.payload, j.error, j.success, j.detail);
// The JSON will have its own id
m.id = j.id;
return m;
}
}
/**
* Possible values for the action argument of psicash().
* @enum {string}
* @readonly
*/
export const PsiCashAction = {
Init: 'init',
PageView: 'page-view',
ClickThrough: 'click-through'
};
/**
* Check if the given action name is valid -- that is, present in PsiCashAction.
* @param {string} action Possibly value action name
* @returns {boolean}
*/
export function PsiCashActionValid(action) {
return Object.values(PsiCashAction).indexOf(action) >= 0;
}
/**
* Get the default timeout for each action.
* @param {PsiCashAction} action
* @returns {number} Milliseconds
*/
export function PsiCashActionDefaultTimeout(action) {
switch (action) {
case PsiCashAction.Init:
return 10000;
case PsiCashAction.PageView:
return 10000;
case PsiCashAction.ClickThrough:
// This is typically going to block the next page from loading, so we don't want it to take too long.
return 1000;
}
return 2000;
}
/**
* Callback for when an iframe message is done being processed.
* @callback PsiCashServerRequestCallback
* @param {Object} result
* @param {?string} result.error Null if no error, otherwise has error message
* @param {number} result.status Like 200, 401, etc. (but not 5xx -- that will just populate `error`)
* @param {string} result.body The body of the response
*/
/**
* @param {Object} config Configuration for the request
* @param {!PsiCashServerRequestCallback} config.callback
* @param {!PsiCashParams} config.psicashParams
* @param {number} config.timeout Milliseconds; if falsy, a default will be used
* @param {!string} config.path Like `/transaction`
* @param {!string} config.method Like `GET`, `POST`, etc.
* @param {!string} config.queryParams In `a=b&c=d` form
*/
export function makePsiCashServerRequest(config) {
return makePsiCashServerRequestHelper(config);
}
function makePsiCashServerRequestHelper(config, start=Date.now(), attempt=1, lastError=null) {
// We're going to interpret "no timeout" as 100s.
config.timeout = config.timeout || 100000;
const remainingTime = config.timeout - (Date.now() - start);
function recurse() {
if (remainingTime < 0) {
// We failed all of our attempts and ran out of time.
const cbResult = {
error: lastError || 'timed out'
};
config.callback(cbResult);
return;
}
// Wait 100ms and try again.
setTimeout(() => makePsiCashServerRequestHelper(config, start, attempt+1, lastError), 100);
}
// We're going to be modifying this object as we make request attempts, so clone it.
config.psicashParams = JSON.parse(JSON.stringify(config.psicashParams));
// We need the request metadata to exist to record the attempt count.
if (!config.psicashParams.metadata) {
config.psicashParams.metadata = {};
}
config.psicashParams.metadata.attempt = attempt;
// For logging and debugging purposes, record the referrer in the metadata, but _not_
// with any potentially-identifying query params or hash.
const pageOrigin = utils.getOrigin(utils.urlComponents(document.referrer));
config.psicashParams.metadata.referrer = pageOrigin + utils.urlComponents(document.referrer).pathname;
const psicashAPIPrefix = config.psicashParams.dev ? consts.PSICASH_API_PREFIX_DEV : consts.PSICASH_API_PREFIX;
let reqURL = `${psicashAPIPrefix}${config.path}`;
if (config.queryParams) {
reqURL += `?${config.queryParams}`;
}
let xhr = new(window.XMLHttpRequest || window.ActiveXObject)('MSXML2.XMLHTTP.3.0');
xhr.open(config.method, reqURL, true);
xhr.timeout = remainingTime;
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('X-PsiCash-Auth', config.psicashParams.tokens);
xhr.setRequestHeader('X-PsiCash-Metadata', JSON.stringify(config.psicashParams.metadata));
xhr.onload = function xhrOnLoad() {
utils.log(xhr.status, xhr.statusText, xhr.responseText, `dev-env:${!!config.psicashParams.dev}`);
if (xhr.status >= 500) {
// Retry
utils.log(`Request to '${config.path}' failed with 500; retrying`, `dev-env:${!!config.psicashParams.dev}`);
lastError = `server error ${xhr.status}`;
return recurse();
}
utils.log(`Request to '${config.path}' completed with ${xhr.status}`, `dev-env:${!!config.psicashParams.dev}`);
const cbResult = {
status: xhr.status,
body: xhr.responseText
};
config.callback(cbResult);
};
xhr.onerror = function xhrOnError() {
// Retry
utils.log(`Request to '${config.path}' error; retrying`, `dev-env:${!!config.psicashParams.dev}`);
lastError = 'request error';
return recurse();
};
xhr.ontimeout = function xhrOnTimeout() {
// Retry
utils.log(`Request to '${config.path}' timeout`, `dev-env:${!!config.psicashParams.dev}`);
lastError = 'request timeout';
return recurse();
};
xhr.send(null);
}