diff --git a/backend/__tests__/service/signupSourceHttp.test.js b/backend/__tests__/service/signupSourceHttp.test.js new file mode 100644 index 0000000000..7d8e4eae22 --- /dev/null +++ b/backend/__tests__/service/signupSourceHttp.test.js @@ -0,0 +1,83 @@ +const express = require('express'); +const request = require('supertest'); +const axios = require('axios'); +const User = require('../../models/User'); +const OAuthLoginState = require('../../models/OAuthLoginState'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); + +jest.mock('axios', () => ({ post: jest.fn(), get: jest.fn() })); +jest.mock('../../services/emailService', () => ({ sendEmail: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../../services/communityPodService', () => ({ ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined) })); + +const app = express(); +app.use(express.json()); +app.use('/api/auth', require('../../routes/auth')); + +describe('signup source over the public HTTP auth surface', () => { + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + beforeEach(() => { + process.env.GITHUB_OAUTH_CLIENT_ID = 'source-test-client'; + process.env.GITHUB_OAUTH_CLIENT_SECRET = 'source-test-secret'; + process.env.REGISTRATION_INVITE_ONLY = 'false'; + delete process.env.AUTH_ALLOWED_EMAILS; + delete process.env.AUTH_ALLOWED_DOMAINS; + delete process.env.SMTP2GO_API_KEY; + }); + afterEach(async () => { + await clearMongoDb(); + jest.clearAllMocks(); + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + delete process.env.REGISTRATION_INVITE_ONLY; + }); + + it('refuses dollar-prefixed OAuth form keys before creating server state', async () => { + const refused = await request(app).post('/api/auth/oauth/github/start').type('form') + .send('$gt=1').expect(400); + expect(refused.body).toMatchObject({ code: 'dollar_key_refused', field: 'body.$gt' }); + expect(await OAuthLoginState.countDocuments({})).toBe(0); + }); + + it('carries bounded form attribution in server state through callback and ignores replay', async () => { + const source = { ref: 'HN', landingPath: `/${'%2F'.repeat(480)}?email=private`, + referrer: `https://news.example/${'%2F'.repeat(480)}?token=private`, firstSeenAt: '2026-01-01T00:00:00.000Z' }; + expect(new URLSearchParams({ signupSource: JSON.stringify(source) }).toString().length).toBeGreaterThan(4096); + const response = await request(app).post('/api/auth/oauth/github/start').type('form') + .send({ signupSource: JSON.stringify(source) }).expect(302); + expect(response.headers.location).not.toMatch(/signupSource|landingPath|private/); + const state = new URL(response.headers.location).searchParams.get('state'); + const stored = await OAuthLoginState.findOne({ state }); + expect(stored.signupSource.landingPath).toBe(source.landingPath.split('?')[0]); + expect(JSON.stringify(stored.signupSource)).not.toContain('private'); + expect(stored.signupSource.referrer).toBe('https://news.example'); + axios.post.mockResolvedValue({ data: { access_token: 'source-test-access' } }); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.endsWith('/emails') + ? [{ email: 'source-http@example.com', verified: true, primary: true }] + : { id: 4245, login: 'source-http' } })); + const callback = `/api/auth/oauth/github/callback?state=${state}&code=source-code`; + await request(app).get(`${callback}&signupSource=forged`).expect(302); + const user = await User.findOne({ email: 'source-http@example.com' }).select('+signupSource'); + expect(user.signupSource.authMethod).toBe('github'); + expect(user.signupSource.ref).toBe('hn'); + const replay = await request(app).get(callback).expect(302); + expect(replay.headers.location).toContain('oauthError=state_invalid'); + expect(await User.countDocuments({ email: 'source-http@example.com' })).toBe(1); + }); + + it('writes the password source only at registration, and never returns it in an ordinary profile read', async () => { + await request(app).post('/api/auth/register').send({ email: 'password-source@example.com', + password: 'Password123!', signupSource: { landingPath: '/v2/invite/SECRETinviteTOKEN123', + referrer: 'https://news.example/v2/invite/SECRETinviteTOKEN123', ref: 'HN' } }).expect(201); + const before = await User.findOne({ email: 'password-source@example.com' }).select('+signupSource'); + expect(before.signupSource.authMethod).toBe('password'); + expect(before.signupSource.landingPath).toBeUndefined(); + expect(before.signupSource.referrer).toBe('https://news.example'); + expect(JSON.stringify(before.signupSource)).not.toContain('SECRETinviteTOKEN123'); + const login = await request(app).post('/api/auth/login').send({ email: 'password-source@example.com', + password: 'Password123!', signupSource: { ref: 'replacement' } }).expect(200); + expect(JSON.stringify(login.body)).not.toContain('signupSource'); + const after = await User.findById(before._id).select('+signupSource'); + expect(after.signupSource.toObject()).toEqual(before.signupSource.toObject()); + }); +}); diff --git a/backend/__tests__/unit/controllers/oauthController.test.js b/backend/__tests__/unit/controllers/oauthController.test.js index 59f4c22f07..9b46d42928 100644 --- a/backend/__tests__/unit/controllers/oauthController.test.js +++ b/backend/__tests__/unit/controllers/oauthController.test.js @@ -7,6 +7,7 @@ const InvitationCode = require('../../../models/InvitationCode'); jest.mock('../../../services/communityPodService', () => ({ ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined), })); +jest.mock('../../../services/emailService', () => ({ sendEmail: jest.fn().mockResolvedValue(undefined) })); const { ensureUserInCommunityPod } = require('../../../services/communityPodService'); const oauthController = require('../../../controllers/oauthController'); const authController = require('../../../controllers/authController'); @@ -81,6 +82,70 @@ describe('OAuth Controller', () => { delete process.env.AUTH_ALLOWED_DOMAINS; }); + describe('signup source round trips', () => { + it.each(['password', 'github', 'google'].flatMap((method) => [[method, false], [method, true]]))( + 'persists sanitized %s first touch once, never from callback parameters (invite path: %s)', async (method, invitePath) => { + const touch = { + ref: 'HN', referrer: invitePath ? 'https://search.example/v2/invite/SECRETinviteTOKEN123' : 'https://search.example/results?email=private#token', + landingPath: invitePath ? '/v2/invite/SECRETinviteTOKEN123' : '/guides/start?invite=private#token', firstSeenAt: '2026-01-01T00:00:00.000Z', + authMethod: 'forged', recordedAt: '1900-01-01', + }; + if (method === 'password') { + const res = mockRes(); + await authController.register({ body: { + username: 'source-user', email: 'source@example.com', password: 'Password123!', signupSource: touch, + } }, res); + expect(res.status).toHaveBeenCalledWith(201); + } else { + if (method === 'google') { + process.env.GOOGLE_OAUTH_CLIENT_ID = 'google-client'; + process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'google-secret'; + } + const signIn = async (source) => { + const startRes = mockRes(); + await oauthController.startOAuth({ ...startReq(method), method: 'POST', + body: { signupSource: JSON.stringify(source) } }, startRes); + const location = startRes.redirect.mock.calls[0][0]; + expect(location).not.toMatch(/search\.example|landingPath|signupSource|private|firstSeenAt/); + const state = new URL(location).searchParams.get('state'); + const storedState = await OAuthLoginState.findOne({ state }); + expect(JSON.stringify(storedState.signupSource)).not.toContain('private'); + expect(JSON.stringify(storedState.signupSource)).not.toContain('SECRETinviteTOKEN123'); + if (method === 'github') mockGithubProfile({ email: 'source@example.com' }); + else { + axios.post.mockResolvedValueOnce({ data: { access_token: 'google-access-token' } }); + axios.get.mockResolvedValue({ data: { sub: 'google-source', email: 'source@example.com', + email_verified: true, name: 'source-user' } }); + } + const callbackRes = mockRes(); + await oauthController.oauthCallback({ ...startReq(method), + query: { code: 'provider-code', state, signupSource: JSON.stringify({ ref: 'forged' }), ref: 'forged' } }, callbackRes); + expect(callbackRes.redirect).toHaveBeenCalledWith(expect.stringContaining('/v2/oauth/complete?')); + expect(callbackRes.redirect.mock.calls[0][0]).not.toMatch(/signupSource|firstSeenAt|search\.example|private/); + return { state, callbackRes }; + }; + const first = await signIn(touch); + // A replay cannot create an account or overwrite its attribution. + const replay = mockRes(); + await oauthController.oauthCallback({ ...startReq(method), query: { state: first.state, code: 'replay' } }, replay); + expect(replay.redirect).toHaveBeenCalledWith(expect.stringContaining('oauthError=state_invalid')); + await signIn({ ref: 'later', landingPath: '/later' }); + } + const user = await User.findOne({ email: 'source@example.com' }).select('+signupSource'); + expect(user.signupSource.toObject()).toEqual({ + ref: 'hn', referrer: 'https://search.example', ...(invitePath ? {} : { landingPath: '/guides/start' }), + firstSeenAt: new Date('2026-01-01T00:00:00.000Z'), authMethod: method, recordedAt: expect.any(Date), + }); + expect(user.onboarding.signupRef).toBe('hn'); + expect((await User.findById(user._id).lean()).signupSource).toBeUndefined(); + // Neither a later save nor a dotted update may rewrite a created source. + user.signupSource.ref = 'changed'; + await user.save(); + await User.updateOne({ _id: user._id }, { $set: { 'signupSource.ref': 'also-changed' } }); + expect((await User.findById(user._id).select('+signupSource')).signupSource.ref).toBe('hn'); + }); + }); + describe('getOAuthProviders', () => { it('lists only configured providers', () => { const res = mockRes(); diff --git a/backend/__tests__/unit/middleware/refuseDollarKeys.test.js b/backend/__tests__/unit/middleware/refuseDollarKeys.test.js index e077e5a445..4493213047 100644 --- a/backend/__tests__/unit/middleware/refuseDollarKeys.test.js +++ b/backend/__tests__/unit/middleware/refuseDollarKeys.test.js @@ -279,6 +279,6 @@ describe('where the guard is mounted', () => { }); test('the OAuth start form runs it after its urlencoded parser', () => { - expect(src('routes/auth.ts')).toMatch(/express\.urlencoded\(\{ extended: false, limit: '4kb' \}\), refuseDollarKeys, startOAuth\)/); + expect(src('routes/auth.ts')).toMatch(/express\.urlencoded\(\{ extended: false, limit: '16kb' \}\), refuseDollarKeys, startOAuth\)/); }); }); diff --git a/backend/__tests__/unit/utils/signupSource.test.js b/backend/__tests__/unit/utils/signupSource.test.js new file mode 100644 index 0000000000..b469e96f9b --- /dev/null +++ b/backend/__tests__/unit/utils/signupSource.test.js @@ -0,0 +1,51 @@ +const { cleanSignupTouch, signupSourceFields } = require('../../../utils/signupSource'); + +describe('signup source privacy boundary', () => { + it('strips query, fragment and URL credentials, and ignores unrecognized keys', () => { + const source = cleanSignupTouch({ + ref: ' X ', referrer: 'https://name:password@news.example/article?email=secret#token', + landingPath: '/guides/a?invite=private#secret', firstSeenAt: '2026-01-01T00:00:00.000Z', + authMethod: 'admin', extra: 'secret', + }); + expect(source).toEqual({ + ref: 'x', referrer: 'https://news.example', landingPath: '/guides/a', + firstSeenAt: new Date('2026-01-01T00:00:00.000Z'), + }); + }); + + it.each([null, [], '{bad', 'x'.repeat(4097), { referrer: 'javascript:secret', landingPath: '//foreign.example', firstSeenAt: 'nope' }])( + 'drops malformed optional input without failing registration: %p', (input) => { + expect(cleanSignupTouch(input)).toEqual({}); + }, + ); + + it.each([ + '/v2/invite/SECRETinviteTOKEN123', + '/v2/INVITE/short', + '/v2/%69nvite/short', + '/v2/%2569nvite%252Fshort', + '/share/0123456789abcdef0123456789abcdef', + '/share/550e8400-e29b-41d4-a716-446655440000', + '/share/AbCdEfGhIjKlMnOpQrStUvWxYz012345', '/v2/%25252569nvite/short', '/bad/%zz', + ])('drops path-embedded capabilities even from an old or forged client: %s', (path) => { + expect(cleanSignupTouch({ landingPath: path, referrer: `https://news.example${path}` })) + .toEqual({ referrer: 'https://news.example' }); + }); + + it('retains a normal guide path while reducing even long referrer paths to an origin', () => { + expect(cleanSignupTouch({ landingPath: '/guides/multi-agent-collaboration-platform', + referrer: `https://news.example/${'a'.repeat(1500)}` })).toEqual({ + landingPath: '/guides/multi-agent-collaboration-platform', referrer: 'https://news.example', + }); + }); + + it('does not turn a later ref into the ref of an earlier direct visit', () => { + expect(cleanSignupTouch({ landingPath: '/' }, 'later')).toEqual({ landingPath: '/' }); + expect(cleanSignupTouch(undefined, 'old-browser')).toEqual({ ref: 'old-browser' }); + }); + + it('sets auth method and recording time on the server, even without browser storage', () => { + const source = signupSourceFields({ authMethod: 'github', recordedAt: '1900-01-01' }, 'password'); + expect(source.signupSource).toEqual({ authMethod: 'password', recordedAt: expect.any(Date) }); + }); +}); diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index 8a38ed72e7..d16f81038d 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -8,7 +8,7 @@ const AgentIdentityService = require('../services/agentIdentityService'); const { sendEmail } = require('../services/emailService'); const { ensureUserInCommunityPod } = require('../services/communityPodService'); const { normalizeAvatarUrl } = require('../services/avatarService'); -const { cleanSignupRef } = require('../utils/signupRef'); +const { signupSourceFields } = require('../utils/signupSource'); const { deriveUsernameFromEmail, ACCOUNT_LABEL_COLLATION } = require('../services/derivedUsername'); const { issueSessionToken } = require('../services/sessionTokenService'); const { @@ -19,13 +19,6 @@ const { disconnectUserSessionSockets } = require('../services/sessionSocketServi const { isInstanceAccessRestricted, isInstanceEmailAllowed, INSTANCE_ACCESS_ERROR } = require('../services/instanceAccessPolicy'); -// The first-touch ref a signup arrived with (?ref=x), stored once on the new -// account. Anything that is not a short plain code is dropped, not refused. -const signupRefField = (raw: unknown) => { - const ref = cleanSignupRef(raw); - return ref ? { onboarding: { signupRef: ref } } : {}; -}; - const joinCommunityPodBestEffort = (userId: any) => { void ensureUserInCommunityPod(userId).catch((err: Error) => { console.warn('[community-pod] background join failed after auth transition:', err.message); @@ -412,6 +405,7 @@ exports.register = async (req: any, res: any) => { password, invitationCode, signupRef, + signupSource, } = req.body; const normalizedEmail = normalizeEmail(email); @@ -534,7 +528,7 @@ exports.register = async (req: any, res: any) => { password: rawPassword, verified: shouldAutoVerify, entitlements: { cloudAgents: invitationRedeemed }, - ...signupRefField(signupRef), + ...signupSourceFields(signupSource, 'password', signupRef), }); // Save user to database diff --git a/backend/controllers/oauthController.ts b/backend/controllers/oauthController.ts index c2672469a3..fd400cdeef 100644 --- a/backend/controllers/oauthController.ts +++ b/backend/controllers/oauthController.ts @@ -4,6 +4,7 @@ const axios = require('axios'); const User = require('../models/User'); const OAuthLoginState = require('../models/OAuthLoginState'); const { cleanSignupRef } = require('../utils/signupRef'); +const { cleanSignupTouch, signupSourceFields } = require('../utils/signupSource'); const AgentIdentityService = require('../services/agentIdentityService'); const { isInviteOnlyRegistrationEnabled, @@ -237,6 +238,9 @@ exports.startOAuth = async (req: any, res: any) => { invitationCode: String(input.invite || '').trim(), next: safeNextPath(input.next), signupRef: cleanSignupRef(input.ref) || '', + // Attribution arrives in a form body, stays in this server-side state, + // and is never copied into a provider or completion URL. + ...(req.method === 'POST' ? { signupSource: cleanSignupTouch(input.signupSource, input.ref) } : {}), expiresAt: new Date(Date.now() + STATE_TTL_MS), }); @@ -336,14 +340,13 @@ exports.oauthCallback = async (req: any, res: any) => { return loginErrorRedirect(res, 'invitation_required'); } - const signupRef = cleanSignupRef(stateRow.signupRef); user = new User({ username: await generateUsername(profile.usernameHint, profile.email), email: profile.email, verified: true, // provider-asserted entitlements: { cloudAgents: invitationRedeemed }, // Set at creation only: signing in to an existing account never changes it. - ...(signupRef ? { onboarding: { signupRef } } : {}), + ...signupSourceFields(stateRow.signupSource, provider, stateRow.signupRef), authProviders: [{ provider, providerId: profile.providerId, diff --git a/backend/models/OAuthLoginState.ts b/backend/models/OAuthLoginState.ts index e4f41dde3d..7c6ae4f610 100644 --- a/backend/models/OAuthLoginState.ts +++ b/backend/models/OAuthLoginState.ts @@ -1,4 +1,5 @@ import mongoose, { Document, Schema, Types } from 'mongoose'; +import type { SignupTouch } from '../utils/signupSource'; // One row per social-login attempt. Lives for the length of the OAuth // round-trip and is deliberately separate from OAuthState (the X/Twitter @@ -22,6 +23,7 @@ export interface IOAuthLoginState extends Document { next?: string; // First-touch ref code (?ref=x) for a signup made through this login. signupRef?: string; + signupSource?: SignupTouch; userId?: Types.ObjectId | null; exchangeCode?: string | null; exchangeExpiresAt?: Date | null; @@ -39,6 +41,9 @@ const OAuthLoginStateSchema = new Schema( invitationCode: { type: String, default: '' }, next: { type: String, default: '/v2' }, signupRef: { type: String, default: '' }, + signupSource: { + type: new Schema({ ref: String, referrer: String, landingPath: String, firstSeenAt: Date }, { _id: false }), + }, userId: { type: Schema.Types.ObjectId, ref: 'User', default: null }, // NO default here — a sparse unique index skips MISSING fields but still // indexes explicit nulls, so `default: null` made every pending state row diff --git a/backend/models/User.ts b/backend/models/User.ts index 3ed0e0c778..1f3d2b8c64 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -220,6 +220,15 @@ export interface IUser extends Document { signupRef?: string; }; createdAt: Date; + /** Browser-reported first touch, persisted only for a new human account. */ + signupSource?: { + ref?: string; + referrer?: string; + landingPath?: string; + firstSeenAt?: Date; + authMethod: 'password' | 'google' | 'github'; + recordedAt: Date; + }; // Instance methods comparePassword(password: string): Promise; generateApiToken(): string; @@ -230,6 +239,20 @@ export interface IUser extends Document { export interface IUserModel extends Model {} const userSchema = new Schema({ + // Growth opts in with .select('+signupSource'); incidental profile/populate + // reads must not disclose referral paths. Every field is write-once too. + signupSource: { + type: new Schema({ + ref: { type: String, immutable: true }, + referrer: { type: String, immutable: true }, + landingPath: { type: String, immutable: true }, + firstSeenAt: { type: Date, immutable: true }, + authMethod: { type: String, enum: ['password', 'google', 'github'], required: true, immutable: true }, + recordedAt: { type: Date, required: true, immutable: true }, + }, { _id: false }), + immutable: true, + select: false, + }, username: { type: String, required: true, unique: true }, previousUsernames: { type: [String], default: [] }, displayName: { type: String, trim: true }, diff --git a/backend/routes/auth.ts b/backend/routes/auth.ts index e82abe6a26..b737c0e713 100644 --- a/backend/routes/auth.ts +++ b/backend/routes/auth.ts @@ -230,7 +230,8 @@ router.get('/oauth/:provider/start', oauthLimiter, startOAuth); // The same start as a form POST, so an invite code rides in the body, not the URL // (the register page uses it whenever it holds a code; TASK-404). // This parser runs after the global $-key guard, so the guard runs again on its body. -router.post('/oauth/:provider/start', oauthLimiter, express.urlencoded({ extended: false, limit: '4kb' }), refuseDollarKeys, startOAuth); +// Two bounded 2 KiB paths in signupSource expand under form URL-encoding. +router.post('/oauth/:provider/start', oauthLimiter, express.urlencoded({ extended: false, limit: '16kb' }), refuseDollarKeys, startOAuth); router.get('/oauth/:provider/callback', oauthLimiter, oauthCallback); router.post('/oauth/exchange', oauthLimiter, exchangeOAuthCode); router.get('/registration-policy', registrationPolicyLimit, getRegistrationPolicy); diff --git a/backend/utils/signupSource.ts b/backend/utils/signupSource.ts new file mode 100644 index 0000000000..d478cd8a1e --- /dev/null +++ b/backend/utils/signupSource.ts @@ -0,0 +1,67 @@ +const { cleanSignupRef } = require('./signupRef'); + +export interface SignupTouch { + ref?: string; + referrer?: string; + landingPath?: string; + firstSeenAt?: Date; +} + +// Keep this rule aligned with frontend/public/signup-source.js. The backend is +// the trust boundary, including for a browser with an older stored touch. +function hasCapabilityPath(pathname: string): boolean { + let decoded = pathname; + for (let i = 0; i < 3 && decoded.includes('%'); i += 1) decoded = decodeURIComponent(decoded); + // Fail closed on malformed or deeper escaping; decodeURIComponent throws on + // malformed escapes and the caller drops the optional path. + if (decoded.includes('%')) return true; + const segments = decoded.split('/'); + return segments.some((segment, i) => ( + (segment.toLowerCase() === 'invite' && i < segments.length - 1) + || /^[a-f0-9]{24,}$/i.test(segment) + || /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(segment) + || (segment.length >= 32 && /^[a-z0-9_.~+=-]+$/i.test(segment) && /[A-Z0-9_.~+=]/.test(segment)) + )); +} + +// Attribution is browser-reported, not authorization. Drop unknown fields and +// URL queries/fragments before either the temporary OAuth state or User sees it. +export function cleanSignupTouch(raw: unknown, legacyRef?: unknown): SignupTouch { + let input: Record = {}; + try { + const value = typeof raw === 'string' && raw.length <= 4096 ? JSON.parse(raw) : raw; + if (value && typeof value === 'object' && !Array.isArray(value)) input = value; + } catch { /* malformed optional attribution does not prevent signup */ } + const result: SignupTouch = {}; + const ref = cleanSignupRef(input.ref) || (!input.landingPath ? cleanSignupRef(legacyRef) : undefined); + if (ref) result.ref = ref; + if (typeof input.referrer === 'string' && input.referrer.length <= 2048) { + try { + const url = new URL(input.referrer); + if (['https:', 'http:'].includes(url.protocol) && url.origin.length <= 256) result.referrer = url.origin; + } catch { /* no valid referrer */ } + } + if (typeof input.landingPath === 'string' && input.landingPath.length <= 2048) { + try { + const url = new URL(input.landingPath, 'https://landing.invalid'); + if (input.landingPath.startsWith('/') && url.origin === 'https://landing.invalid' + && !url.pathname.startsWith('//') && !hasCapabilityPath(url.pathname)) result.landingPath = url.pathname; + } catch { /* no valid path */ } + } + const seen = input.firstSeenAt instanceof Date ? input.firstSeenAt + : typeof input.firstSeenAt === 'string' && input.firstSeenAt.length <= 32 + ? new Date(input.firstSeenAt) : undefined; + if (seen && Number.isFinite(seen.getTime()) && seen.getTime() <= Date.now()) { + result.firstSeenAt = seen; + } + return result; +} + +/** Called only in new-account constructors, never in login/link/update paths. */ +export function signupSourceFields(raw: unknown, authMethod: 'password' | 'google' | 'github', legacyRef?: unknown) { + const touch = cleanSignupTouch(raw, legacyRef); + return { + signupSource: { ...touch, authMethod, recordedAt: new Date() }, + ...(touch.ref ? { onboarding: { signupRef: touch.ref } } : {}), + }; +} diff --git a/docs/design/evidence/signup-source-2026-10-25/browser.jsonl b/docs/design/evidence/signup-source-2026-10-25/browser.jsonl new file mode 100644 index 0000000000..fe72f03c7a --- /dev/null +++ b/docs/design/evidence/signup-source-2026-10-25/browser.jsonl @@ -0,0 +1,2 @@ +{"width":390,"overflow":false,"buttons":[{"width":322,"height":40},{"width":322,"height":40}],"method":"POST","query":"","source":{"landingPath":"/guides/start","firstSeenAt":"2026-10-25T15:25:30.086Z","ref":"hn"}} +{"width":1440,"overflow":false,"buttons":[{"width":322,"height":40},{"width":322,"height":40}],"method":"POST","query":"","source":{"landingPath":"/guides/start","firstSeenAt":"2026-10-25T15:25:31.671Z","ref":"hn"}} diff --git a/docs/design/evidence/signup-source-2026-10-25/invite-browser.jsonl b/docs/design/evidence/signup-source-2026-10-25/invite-browser.jsonl new file mode 100644 index 0000000000..f1d6401f27 --- /dev/null +++ b/docs/design/evidence/signup-source-2026-10-25/invite-browser.jsonl @@ -0,0 +1,2 @@ +{"width":390,"overflow":false,"buttons":[{"width":322,"height":40},{"width":322,"height":40}],"method":"POST","query":"","source":{"firstSeenAt":"2026-10-25T16:58:35.087Z","ref":"hn","referrer":"http://127.0.0.1:14525"}} +{"width":1440,"overflow":false,"buttons":[{"width":322,"height":40},{"width":322,"height":40}],"method":"POST","query":"","source":{"firstSeenAt":"2026-10-25T16:58:36.159Z","ref":"hn","referrer":"http://127.0.0.1:14525"}} diff --git a/docs/design/evidence/signup-source-2026-10-25/register-1440.png b/docs/design/evidence/signup-source-2026-10-25/register-1440.png new file mode 100644 index 0000000000..f7e987e606 Binary files /dev/null and b/docs/design/evidence/signup-source-2026-10-25/register-1440.png differ diff --git a/docs/design/evidence/signup-source-2026-10-25/register-390.png b/docs/design/evidence/signup-source-2026-10-25/register-390.png new file mode 100644 index 0000000000..bc0d2db910 Binary files /dev/null and b/docs/design/evidence/signup-source-2026-10-25/register-390.png differ diff --git a/docs/development/signup-source.md b/docs/development/signup-source.md new file mode 100644 index 0000000000..a9903e602b --- /dev/null +++ b/docs/development/signup-source.md @@ -0,0 +1,48 @@ +# Signup source + +New human accounts retain an optional first browser touch in `User.signupSource`. +The auth method (`password`, `github`, `google`) and `recordedAt` are supplied by +the server at creation. `ref`, `referrer`, `landingPath` and `firstSeenAt` are +browser-reported attribution, not verified identity or authorization facts. +`firstSeenAt` is optional when storage was blocked or the browser clock was invalid. + +`frontend/public/signup-source.js` runs from the real `frontend/index.html` +before React or redirects. Static public pages keep the script when the SEO +generator removes the React bundle. The first stored touch wins even when it +has no ref. Previously saved `commonly.firstTouchRef` codes remain usable for +older browsers. The old `onboarding.signupRef` is retained for Growth's readers. + +Only a short ref code, an HTTP(S) referrer **origin**, an optional app-relative +landing path, and a timestamp are retained. Referrer paths are always discarded, +along with URL queries, fragments and userinfo; the stored origin is at most +256 characters. Landing inputs are bounded at 2048 characters. Both the browser +and server drop the entire landing path if a decoded segment is `invite` with +a following segment, a hex token of at least 24 characters, a UUID, or a token-like +segment of at least 32 characters containing uppercase letters, digits or token +punctuation. Matching decodes up to three layers; malformed or deeper escaping +also drops the path. Thus `/v2/invite/` is absent from attribution, while +normal guide paths remain. This is a conservative filter for known routes and +token shapes, not a promise to identify every possible secret in arbitrary text. +New token-bearing routes must extend this rule or use a static route template. + +The whole browser payload is bounded at 4096. The server independently sanitizes +the input before temporary OAuth storage and again before creating the account, +including input from an older browser that still has an unsanitized saved touch. + +Password registration sends the source in its JSON body. OAuth buttons with +attribution use a form POST to `/api/auth/oauth/:provider/start`; the source +stays in the expiring, one-use `OAuthLoginState` row. Only the random state token +goes to the provider. Callbacks and completion URLs never carry the source. +The historical GET start still works for old clients; it accepts no new source +object. An existing-account login or provider link never sets or overwrites it. + +The User schema makes the source and its fields immutable and excludes it from +ordinary queries. Growth's authorized server-side reads opt in with +`.select('+signupSource')`; no public profile field or new read endpoint is added. +Existing accounts remain without this field. No historical source is invented. + +Verification covers all three auth methods against persisted Mongo rows, +real HTTP form parsing and login/replay paths, immutable dotted updates, +query/fragment/userinfo removal, decoded invite/token paths in both attribution +fields, referrer-origin reduction, static public pages, blocked browser storage, +and the OAuth form request in isolated Chromium at 390 and 1440. diff --git a/frontend/index.html b/frontend/index.html index 1e598bb56d..4a288d1192 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ +