diff --git a/offline/test/oauth-dest.test.js b/offline/test/oauth-dest.test.js new file mode 100644 index 0000000..a58c0df --- /dev/null +++ b/offline/test/oauth-dest.test.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +/** + * The OAuth destination — the value that survives the bounce to the provider. + * + * WHY THIS EXISTS. A campaign CTA names where the visitor is going: + * + * #/desk/billing?plan=team&cycle=monthly&tab=checkout&promo=EMAILMKT270826_2 + * + * ui-team parks that in sessionStorage before the signin plugin rewrites the + * hash, which carries an email/password sign-in and not an OAuth one: this + * callback is server-side, it rebuilds the landing URL from scratch, and a URL + * fragment is never sent to a server in the first place. So the destination + * rides on `oauth_state` beside `ref` and utm_*, and comes back out on `home`. + * + * TWO THINGS ARE PINNED HERE, and the second is why this file is not optional: + * + * 1. _sanitiseDest accepts exactly the destinations the campaign can name and + * refuses everything else. It REBUILDS rather than passes through, so a + * value can only ever be one this function could have written. + * + * 2. The landing templates do not interpolate that URL raw. lib/loby.js uses + * LODASH, whose equals-delimiter is the RAW one — the reverse of EJS — so + * `location.replace('')` put request-derived text straight into a + * JS string literal on the page that runs immediately after + * authentication. Both halves are asserted: the sanitiser refuses quotes, + * AND the template escapes. Either alone is one edit away from an XSS. + * + * Standalone runner (no test framework in this repo): `node `. + */ + +const assert = require("assert"); +const { readFileSync } = require("fs"); +const { join } = require("path"); +const { template } = require("lodash"); + +const ROOT = join(__dirname, "../.."); +const read = (p) => readFileSync(join(ROOT, p), "utf8"); +const LOBY = read("service/lib/loby.js"); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log(" ok " + name); } + catch (e) { failures++; console.log(" FAIL " + name + "\n " + e.message); } +} + +/** + * The real _sanitiseDest, lifted and compiled. + * + * Out of the source rather than restated here, so these cases cannot pass + * against a rule the service does not actually apply. + */ +const sanitiseSrc = /_sanitiseDest\(raw\) \{([\s\S]*?)\n \}/.exec(LOBY); +assert(sanitiseSrc, "_sanitiseDest not found in service/lib/loby.js"); +const sanitise = new Function("raw", sanitiseSrc[1]); + +const CAMPAIGN_DEST = + "/desk/billing?plan=team&cycle=monthly&tab=checkout&promo=EMAILMKT270826_2"; + +// ── what it accepts ──────────────────────────────────────────────────── +test("the campaign's own destination survives byte for byte", () => { + assert.strictEqual(sanitise(CAMPAIGN_DEST), CAMPAIGN_DEST); +}); + +test("the bare billing path is a destination", () => { + assert.strictEqual(sanitise("/desk/billing"), "/desk/billing"); +}); + +test("params are rebuilt in a fixed order", () => { + // Two links meaning the same thing must produce the same string: the value is + // compared and stored, and an order-dependent one would look like two. + assert.strictEqual( + sanitise("/desk/billing?tab=checkout&plan=team"), + "/desk/billing?plan=team&tab=checkout"); +}); + +test("whitespace around the value is tolerated", () => { + assert.strictEqual(sanitise(` ${CAMPAIGN_DEST} `), CAMPAIGN_DEST); +}); + +// ── what it refuses ──────────────────────────────────────────────────── +const REFUSED = { + "a foreign path": "/desk/wm/open/123", + "a path outside the desk": "/welcome/signin", + "a protocol-relative url": "//evil.example/", + "an absolute url": "https://evil.example/desk/billing", + "a quote (the XSS vector)": "/desk/billing?plan=team');alert(1);//", + "a double quote": '/desk/billing?plan=team"x', + "a backslash": "/desk/billing?plan=team\\x", + "an angle bracket": "/desk/billing?plan= + + <% } %> <% if (typeof is_new !== 'undefined' && is_new) { %> + Discover your Drumee desk diff --git a/service/templates/otp-challenge.html b/service/templates/otp-challenge.html index 8a61f4a..b8db0d1 100644 --- a/service/templates/otp-challenge.html +++ b/service/templates/otp-challenge.html @@ -4,7 +4,10 @@ Verifying… - + +