Skip to content

Commit eec2387

Browse files
committed
Add submodule for private risk engine and refactor risk score calculations
- Introduced a new submodule for the private risk engine, allowing dynamic loading of risk assessment logic. - Refactored risk score calculations in `risk-scores.tsx` to improve clarity and maintainability, including the addition of new types for risk assessment. - Updated `sign-up-heuristics.tsx` to create neutral heuristic facts, simplifying the handling of sign-up data. - Enhanced test cases to validate new risk score behaviors and ensure robust functionality across sign-up processes.
1 parent 94626ee commit eec2387

8 files changed

Lines changed: 452 additions & 1260 deletions

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "packages/private"]
2+
path = packages/private
3+
url = https://github.com/stack-auth/private.git

apps/backend/src/lib/risk-scores.tsx

Lines changed: 199 additions & 179 deletions
Large diffs are not rendered by default.
Lines changed: 18 additions & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,3 @@
1-
import { isIpAddress } from "@stackframe/stack-shared/dist/utils/ips";
2-
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
3-
import { normalizeEmail } from "./emails";
4-
5-
type EmailProviderRule = {
6-
canonicalDomain: string,
7-
stripPlusTag: boolean,
8-
stripDots: boolean,
9-
};
10-
11-
const emailProviderRules = new Map<string, EmailProviderRule>([
12-
["gmail.com", { canonicalDomain: "gmail.com", stripPlusTag: true, stripDots: true }],
13-
["googlemail.com", { canonicalDomain: "gmail.com", stripPlusTag: true, stripDots: true }],
14-
["outlook.com", { canonicalDomain: "outlook.com", stripPlusTag: true, stripDots: false }],
15-
["hotmail.com", { canonicalDomain: "hotmail.com", stripPlusTag: true, stripDots: false }],
16-
["live.com", { canonicalDomain: "live.com", stripPlusTag: true, stripDots: false }],
17-
["msn.com", { canonicalDomain: "msn.com", stripPlusTag: true, stripDots: false }],
18-
["icloud.com", { canonicalDomain: "icloud.com", stripPlusTag: true, stripDots: false }],
19-
["me.com", { canonicalDomain: "icloud.com", stripPlusTag: true, stripDots: false }],
20-
["mac.com", { canonicalDomain: "icloud.com", stripPlusTag: true, stripDots: false }],
21-
["fastmail.com", { canonicalDomain: "fastmail.com", stripPlusTag: true, stripDots: false }],
22-
]);
23-
241
export type DerivedSignUpHeuristicFacts = {
252
signUpAt: Date,
263
signUpIp: string | null,
@@ -31,181 +8,28 @@ export type DerivedSignUpHeuristicFacts = {
318
emailBase: string | null,
329
};
3310

34-
export function normalizeSignUpHeuristicIp(ipAddress: string | null): string | null {
35-
if (ipAddress == null) {
36-
return null;
37-
}
38-
39-
const normalized = ipAddress.trim().toLowerCase();
40-
if (!isIpAddress(normalized)) {
41-
throw new StackAssertionError("Expected sign-up heuristic IP address to already be valid", { ipAddress });
42-
}
43-
44-
return normalized;
45-
}
46-
47-
function normalizeEmailParts(primaryEmail: string | null): { localPart: string, domain: string } | null {
48-
if (primaryEmail == null) {
49-
return null;
50-
}
51-
52-
const normalized = normalizeEmail(primaryEmail);
53-
const atIndex = normalized.indexOf("@");
54-
if (atIndex < 0) {
55-
throw new StackAssertionError("normalizeEmail returned an invalid address shape", { primaryEmail, normalized });
56-
}
57-
const localPart = normalized.slice(0, atIndex);
58-
const domain = normalized.slice(atIndex + 1);
59-
60-
return { localPart, domain };
61-
}
62-
63-
export function normalizeEmailForSignUpHeuristics(primaryEmail: string | null): string | null {
64-
const parts = normalizeEmailParts(primaryEmail);
65-
if (parts == null) {
66-
return null;
67-
}
68-
69-
const providerRule = emailProviderRules.get(parts.domain);
70-
const canonicalDomain = providerRule?.canonicalDomain ?? parts.domain;
71-
72-
let canonicalLocalPart = parts.localPart;
73-
if (providerRule?.stripPlusTag) {
74-
canonicalLocalPart = canonicalLocalPart.split("+")[0] ?? canonicalLocalPart;
75-
}
76-
if (providerRule?.stripDots) {
77-
canonicalLocalPart = canonicalLocalPart.replace(/\./g, "");
78-
}
79-
80-
return `${canonicalLocalPart}@${canonicalDomain}`;
81-
}
82-
83-
export function getBaseEmailForSignUpHeuristics(primaryEmail: string | null): string | null {
84-
const parts = normalizeEmailParts(primaryEmail);
85-
if (parts == null) {
86-
return null;
87-
}
88-
89-
const canonicalDomain = emailProviderRules.get(parts.domain)?.canonicalDomain ?? parts.domain;
90-
const dealiased = parts.localPart.replace(/\+.*$/, "");
91-
const base = dealiased
92-
.replace(/[._-]+/g, "-") // normalize separators to a single dash
93-
.replace(/(-\d+)+$/, "") // strip trailing -N segments (e.g. alice-12-34 → alice)
94-
.replace(/\d+$/, "") // strip remaining bare trailing digits (e.g. alice123 → alice)
95-
.replace(/(^-|-$)/g, ""); // trim leading/trailing dashes
96-
97-
return `${base || dealiased || parts.localPart}@${canonicalDomain}`;
98-
}
99-
100-
export function deriveSignUpHeuristicFacts(params: {
101-
primaryEmail: string | null,
102-
ipAddress: string | null,
103-
ipTrusted: boolean | null,
104-
recordedAt?: Date,
105-
}): DerivedSignUpHeuristicFacts {
106-
const recordedAt = params.recordedAt ?? new Date();
107-
const normalizedIp = normalizeSignUpHeuristicIp(params.ipAddress);
108-
const emailNormalized = normalizeEmailForSignUpHeuristics(params.primaryEmail);
109-
const emailBase = getBaseEmailForSignUpHeuristics(params.primaryEmail);
110-
11+
export function createNeutralSignUpHeuristicFacts(recordedAt: Date = new Date()): DerivedSignUpHeuristicFacts {
11112
return {
11213
signUpAt: recordedAt,
113-
signUpIp: normalizedIp,
114-
signUpIpTrusted: normalizedIp == null ? null : params.ipTrusted,
115-
signUpEmailNormalized: emailNormalized,
116-
signUpEmailBase: emailBase,
117-
emailNormalized,
118-
emailBase,
14+
signUpIp: null,
15+
signUpIpTrusted: null,
16+
signUpEmailNormalized: null,
17+
signUpEmailBase: null,
18+
emailNormalized: null,
19+
emailBase: null,
11920
};
12021
}
12122

122-
import.meta.vitest?.test("normalizeEmailForSignUpHeuristics(...)", ({ expect }) => {
123-
const localPartCases = [
124-
{ localPart: "Example.Test+123", expectedByDomain: new Map([
125-
["googlemail.com", "exampletest@gmail.com"],
126-
["gmail.com", "exampletest@gmail.com"],
127-
["outlook.com", "example.test@outlook.com"],
128-
["example.com", "example.test+123@example.com"],
129-
]) },
130-
{ localPart: "Jane.Doe", expectedByDomain: new Map([
131-
["googlemail.com", "janedoe@gmail.com"],
132-
["gmail.com", "janedoe@gmail.com"],
133-
["outlook.com", "jane.doe@outlook.com"],
134-
["example.com", "jane.doe@example.com"],
135-
]) },
136-
];
137-
138-
for (const localPartCase of localPartCases) {
139-
for (const [domain, expected] of localPartCase.expectedByDomain) {
140-
expect(normalizeEmailForSignUpHeuristics(`${localPartCase.localPart}@${domain}`)).toBe(expected);
141-
}
142-
}
143-
144-
expect(normalizeEmailForSignUpHeuristics(null)).toBeNull();
145-
});
146-
147-
import.meta.vitest?.test("getBaseEmailForSignUpHeuristics(...)", ({ expect }) => {
148-
const baseLocalPart = "alice";
149-
const noisySuffixes = ["+1", "+2", "-3", "_004", ".005", "--006"];
150-
for (const suffix of noisySuffixes) {
151-
expect(getBaseEmailForSignUpHeuristics(`${baseLocalPart}${suffix}@example.com`)).toBe("alice@example.com");
152-
}
153-
154-
// Plus aliases are stripped regardless of content (not just numeric suffixes)
155-
const plusAliasCases = ["alice+sales@example.com", "alice+team@example.com", "alice+abc123@example.com"];
156-
for (const plusAliasCase of plusAliasCases) {
157-
expect(getBaseEmailForSignUpHeuristics(plusAliasCase)).toBe("alice@example.com");
158-
}
159-
160-
// Turnstile demo pattern: random hex plus tags all map to the same base
161-
const demoEmails = ["turnstile-demo+a1b2c3d4@example.com", "turnstile-demo+e5f6a7b8@example.com"];
162-
for (const demoEmail of demoEmails) {
163-
expect(getBaseEmailForSignUpHeuristics(demoEmail)).toBe("turnstile-demo@example.com");
164-
}
23+
import.meta.vitest?.test("createNeutralSignUpHeuristicFacts(...)", ({ expect }) => {
24+
const recordedAt = new Date("2026-03-11T00:00:00.000Z");
16525

166-
// Gmail plus aliases also map to the same base
167-
expect(getBaseEmailForSignUpHeuristics("alice+1@gmail.com")).toBe("alice@gmail.com");
168-
expect(getBaseEmailForSignUpHeuristics("alice+sales@gmail.com")).toBe("alice@gmail.com");
169-
});
170-
171-
import.meta.vitest?.test("deriveSignUpHeuristicFacts(...)", ({ expect }) => {
172-
const recordedAt = new Date("2026-03-10T00:00:00.000Z");
173-
const cases = [
174-
{
175-
primaryEmail: "alice+1@example.com",
176-
ipAddress: " 127.0.0.1 ",
177-
ipTrusted: false,
178-
expected: {
179-
signUpIp: "127.0.0.1",
180-
signUpIpTrusted: false,
181-
signUpEmailNormalized: "alice+1@example.com",
182-
signUpEmailBase: "alice@example.com",
183-
},
184-
},
185-
{
186-
primaryEmail: "Example.Test+123@googlemail.com",
187-
ipAddress: null,
188-
ipTrusted: true,
189-
expected: {
190-
signUpIp: null,
191-
signUpIpTrusted: null,
192-
signUpEmailNormalized: "exampletest@gmail.com",
193-
signUpEmailBase: "example-test@gmail.com",
194-
},
195-
},
196-
];
197-
198-
for (const testCase of cases) {
199-
expect(deriveSignUpHeuristicFacts({
200-
primaryEmail: testCase.primaryEmail,
201-
ipAddress: testCase.ipAddress,
202-
ipTrusted: testCase.ipTrusted,
203-
recordedAt,
204-
})).toMatchObject({
205-
signUpAt: recordedAt,
206-
...testCase.expected,
207-
emailNormalized: testCase.expected.signUpEmailNormalized,
208-
emailBase: testCase.expected.signUpEmailBase,
209-
});
210-
}
26+
expect(createNeutralSignUpHeuristicFacts(recordedAt)).toEqual({
27+
signUpAt: recordedAt,
28+
signUpIp: null,
29+
signUpIpTrusted: null,
30+
signUpEmailNormalized: null,
31+
signUpEmailBase: null,
32+
emailNormalized: null,
33+
emailBase: null,
34+
});
21135
});

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"use client";
22

3-
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
43
import { UserTable } from "@/components/data-table/user-table";
54
import { ExportUsersDialog } from "@/components/export-users-dialog";
65
import { StyledLink } from "@/components/link";
76
import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui";
8-
import { cn } from "@/lib/utils";
97
import { UserDialog } from "@/components/user-dialog";
8+
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
9+
import { cn } from "@/lib/utils";
1010
import { ArrowsClockwiseIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
1111
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
1212
import { Suspense, useCallback, useState } from "react";

apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-test.test.ts

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
1-
import { riskScoreWeights } from "@stackframe/stack-shared/dist/utils/risk-score-weights";
21
import { describe } from "vitest";
32
import { it } from "../../../../../helpers";
43
import { Project, backendContext, niceBackendFetch } from "../../../../backend-helpers";
54

6-
type ScorePair = {
7-
bot: number,
8-
free_trial_abuse: number,
9-
};
10-
115
const EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN = "emailable-not-deliverable.example.com";
126

13-
function sumScores(...contributions: ScorePair[]): ScorePair {
14-
return {
15-
bot: Math.min(100, contributions.reduce((score, contribution) => score + contribution.bot, 0)),
16-
free_trial_abuse: Math.min(100, contributions.reduce((score, contribution) => score + contribution.free_trial_abuse, 0)),
17-
};
7+
function expectValidRiskScores(expect: typeof import("vitest").expect, scores: { bot: number, free_trial_abuse: number }) {
8+
expect(scores.bot).toBeGreaterThanOrEqual(0);
9+
expect(scores.bot).toBeLessThanOrEqual(100);
10+
expect(scores.free_trial_abuse).toBeGreaterThanOrEqual(0);
11+
expect(scores.free_trial_abuse).toBeLessThanOrEqual(100);
12+
expect(Number.isInteger(scores.bot)).toBe(true);
13+
expect(Number.isInteger(scores.free_trial_abuse)).toBe(true);
1814
}
1915

20-
const derivedTurnstileRiskScore = riskScoreWeights.turnstile;
21-
const derivedNonDeliverablePasswordRiskScore = sumScores(riskScoreWeights.emailable, riskScoreWeights.turnstile);
22-
2316
describe("with admin access", () => {
2417
it("uses default action when no rules match", async ({ expect }) => {
2518
await Project.createAndSwitch({ config: {} });
@@ -113,7 +106,7 @@ describe("with admin access", () => {
113106
enabled: true,
114107
displayName: "Block high bot score",
115108
priority: 1,
116-
condition: `riskScores.bot >= ${derivedNonDeliverablePasswordRiskScore.bot}`,
109+
condition: "riskScores.bot >= 50",
117110
action: {
118111
type: "reject",
119112
message: "High bot risk",
@@ -153,15 +146,15 @@ describe("with admin access", () => {
153146
});
154147
});
155148

156-
it("derives risk score conditions from disposable-email heuristics", async ({ expect }) => {
149+
it("returns derived risk_scores when no override is provided", async ({ expect }) => {
157150
await Project.createAndSwitch();
158151
backendContext.set({ ipData: undefined });
159152
await Project.updateConfig({
160153
"auth.signUpRules.block-high-bot-score": {
161154
enabled: true,
162155
displayName: "Block high bot score",
163156
priority: 1,
164-
condition: `riskScores.bot >= ${derivedNonDeliverablePasswordRiskScore.bot}`,
157+
condition: "riskScores.bot >= 1",
165158
action: {
166159
type: "reject",
167160
message: "High bot risk",
@@ -182,29 +175,22 @@ describe("with admin access", () => {
182175
});
183176

184177
expect(response.status).toBe(200);
185-
expect(response.body).toMatchObject({
186-
context: {
187-
risk_scores: {
188-
...derivedNonDeliverablePasswordRiskScore,
189-
},
190-
},
191-
outcome: {
192-
should_allow: false,
193-
decision: "reject",
194-
decision_rule_id: "block-high-bot-score",
195-
},
178+
expectValidRiskScores(expect, response.body.context.risk_scores);
179+
expect(response.body.outcome).toMatchObject({
180+
should_allow: response.body.context.risk_scores.bot >= 1 ? false : true,
181+
decision: response.body.context.risk_scores.bot >= 1 ? "reject" : "default-allow",
196182
});
197183
});
198184

199-
it("derives risk score conditions from Turnstile overrides unless risk_scores are overridden", async ({ expect }) => {
185+
it("uses derived risk_scores for turnstile input unless risk_scores are overridden", async ({ expect }) => {
200186
await Project.createAndSwitch();
201187
backendContext.set({ ipData: undefined });
202188
await Project.updateConfig({
203189
"auth.signUpRules.block-high-bot-score": {
204190
enabled: true,
205191
displayName: "Block high bot score",
206192
priority: 1,
207-
condition: `riskScores.bot >= ${derivedTurnstileRiskScore.bot}`,
193+
condition: "riskScores.bot >= 1",
208194
action: {
209195
type: "reject",
210196
message: "High bot risk",
@@ -226,19 +212,8 @@ describe("with admin access", () => {
226212
});
227213

228214
expect(derivedResponse.status).toBe(200);
229-
expect(derivedResponse.body).toMatchObject({
230-
context: {
231-
turnstile_result: "invalid",
232-
risk_scores: {
233-
...derivedTurnstileRiskScore,
234-
},
235-
},
236-
outcome: {
237-
should_allow: false,
238-
decision: "reject",
239-
decision_rule_id: "block-high-bot-score",
240-
},
241-
});
215+
expect(derivedResponse.body.context.turnstile_result).toBe("invalid");
216+
expectValidRiskScores(expect, derivedResponse.body.context.risk_scores);
242217

243218
const overriddenResponse = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", {
244219
method: "POST",
@@ -250,8 +225,8 @@ describe("with admin access", () => {
250225
country_code: null,
251226
turnstile_result: "invalid",
252227
risk_scores: {
253-
bot: 0,
254-
free_trial_abuse: 0,
228+
bot: 10,
229+
free_trial_abuse: 10,
255230
},
256231
},
257232
});
@@ -261,13 +236,14 @@ describe("with admin access", () => {
261236
context: {
262237
turnstile_result: "invalid",
263238
risk_scores: {
264-
bot: 0,
265-
free_trial_abuse: 0,
239+
bot: 10,
240+
free_trial_abuse: 10,
266241
},
267242
},
268243
outcome: {
269-
should_allow: true,
270-
decision: "default-allow",
244+
should_allow: false,
245+
decision: "reject",
246+
decision_rule_id: "block-high-bot-score",
271247
},
272248
});
273249
});

0 commit comments

Comments
 (0)