From 4a5b6e71bf36ffe9ca9582912215266decfeb93a Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Thu, 27 Aug 2026 14:49:10 -0700 Subject: [PATCH 1/2] feat(oauth): carry the visitor's destination across the provider bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initiate parks `dest` on oauth_state beside ref and utm_*; the callback reads it back and appends it to the `home` URL it already builds. A signed-out visitor who clicks a campaign CTA and signs in with Google or Apple now lands where the link named instead of on a bare desk. THE DESTINATION GOES ON THE URL, not into storage. The visitor may land on a different deploy slot from the one they clicked (measured on stage: clicked /-/huan/, landed /-/), and a fragment could never have reached this server anyway. ui-team's billing-deep-link consume() already reads a destination off the URL when storage has none — so nothing changes over there, which was worth verifying rather than assuming. ALL THREE SUCCESSFUL EXITS carry it: existing sign-in, new account (the welcome card's CTA) and 2FA (through the OTP screen). Missing one would make the feature work for some users and silently not for others. _sanitiseDest IS A SHAPE CHECK THAT REBUILDS, not an escaping pass: one path, four params, each matched against its own anchored regex, and the string assembled again from what survived — so a value can only ever be one this function could have written. An unknown param is REFUSED rather than dropped, because honouring half a link written against a contract we do not have is how a destination becomes a wrong one rather than an absent one. Run at both ends: the row is data, and the code that builds a template's input is the code that has to have checked it. AND THE TEMPLATES NO LONGER INTERPOLATE THAT URL RAW. lib/loby.js renders with 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 landing templates now emit it through JSON.stringify, and the new-account CTA through the escaping delimiter. The sanitiser refusing quotes and the template escaping are independent; either alone is one edit away from an XSS. The initiate INSERT now degrades one column-group at a time (dest → utm → ref → bare) rather than falling straight to the bare row: a database without `dest` must still keep the campaign. Signing in never depends on any of it. Two guards in the sanitiser — the character check and the 255 cap — cannot currently decide anything, verified by mutation: with the path fixed and every param bounded, the longest value it can emit is 125 chars. They are kept as defence in depth, and the suite pins the PREMISE instead (every param regex is anchored and quote-free), so adding a free-text param fails loudly at the moment those guards start doing real work. Co-Authored-By: Claude Opus 5 (1M context) --- offline/test/oauth-dest.test.js | 232 +++++++++++++++++++++++++ service/apple.js | 63 +++++-- service/google.js | 67 +++++-- service/lib/loby.js | 109 +++++++++++- service/templates/account-created.html | 22 ++- service/templates/otp-challenge.html | 5 +- 6 files changed, 463 insertions(+), 35 deletions(-) create mode 100644 offline/test/oauth-dest.test.js diff --git a/offline/test/oauth-dest.test.js b/offline/test/oauth-dest.test.js new file mode 100644 index 0000000..9047af5 --- /dev/null +++ b/offline/test/oauth-dest.test.js @@ -0,0 +1,232 @@ +#!/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… - + +