apira-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +296 -0
- package/demo/index.html +168 -0
- package/dist/engine.d.ts +47 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +125 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +348 -0
- package/dist/risk.d.ts +9 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +17 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +42 -0
- package/package.json +45 -0
- package/src/engine.ts +193 -0
- package/src/index.ts +507 -0
- package/src/risk.ts +36 -0
- package/src/server.ts +80 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O(1) behaviour detection engine — shared by all surfaces.
|
|
3
|
+
*
|
|
4
|
+
* All counters use fixed-window arithmetic so every operation is constant time:
|
|
5
|
+
* no timestamp arrays, no filter/scan, no history queries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RiskReason } from "./risk.js";
|
|
9
|
+
|
|
10
|
+
// ─── public API ──────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type TrackEvent = {
|
|
13
|
+
now?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Milliseconds since the watcher was attached (submit surface).
|
|
16
|
+
* Presence of this field marks the event as a submit-surface event:
|
|
17
|
+
* - enables fast_action and retry signals
|
|
18
|
+
* - disables enumeration / endpoint_concentration / flow_anomaly
|
|
19
|
+
*/
|
|
20
|
+
elapsed?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Request URL (access surface).
|
|
23
|
+
* Presence of this field marks the event as an access-surface event:
|
|
24
|
+
* - enables enumeration, endpoint_concentration, flow_anomaly
|
|
25
|
+
* - disables retry
|
|
26
|
+
*/
|
|
27
|
+
url?: string;
|
|
28
|
+
/** HTTP method — required for flow_anomaly (access surface only). */
|
|
29
|
+
method?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type EngineOptions = {
|
|
33
|
+
/** Requests per velocityWindowMs before "velocity" fires. Default: 60. */
|
|
34
|
+
velocityThreshold?: number;
|
|
35
|
+
/** Sliding window for velocity. Default: 60 000 ms. */
|
|
36
|
+
velocityWindowMs?: number;
|
|
37
|
+
/** Requests per 10 s before "spike" fires. Default: 20. */
|
|
38
|
+
spikeThreshold?: number;
|
|
39
|
+
/** Requests to the same normalised endpoint before "endpoint_concentration" fires. Default: 20. */
|
|
40
|
+
concThreshold?: number;
|
|
41
|
+
/** Minimum error rate (0–1) before "probing" fires (needs ≥ 5 requests). Default: 0.5. */
|
|
42
|
+
errorRateThreshold?: number;
|
|
43
|
+
/** Elapsed ms threshold for "fast_action". Default: 3 000. */
|
|
44
|
+
fastActionMs?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type Engine = {
|
|
48
|
+
track(key: string, event: TrackEvent): RiskReason[];
|
|
49
|
+
/** Call after a 4xx / 5xx response to feed the probing detector. */
|
|
50
|
+
reportError(key: string): void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ─── internal state per tracked key ─────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
type State = {
|
|
56
|
+
// Velocity — O(1) fixed window
|
|
57
|
+
winStart: number;
|
|
58
|
+
winCount: number;
|
|
59
|
+
// Spike — O(1) fixed 10 s window
|
|
60
|
+
spikeStart: number;
|
|
61
|
+
spikeCount: number;
|
|
62
|
+
// Total calls — never reset (retry on submit surface, flow-anomaly guard on access)
|
|
63
|
+
totalCount: number;
|
|
64
|
+
// Enumeration — O(1) sequential-run tracker
|
|
65
|
+
lastPathNum: number | null;
|
|
66
|
+
seqRun: number;
|
|
67
|
+
// Endpoint concentration — O(1) per-endpoint counter inside velocity window
|
|
68
|
+
epWinStart: number;
|
|
69
|
+
epCounts: Map<string, number>;
|
|
70
|
+
// Probing — O(1) error-rate tracker
|
|
71
|
+
reqCount: number; // increments on track(); never reset
|
|
72
|
+
errCount: number; // increments on reportError(); never reset
|
|
73
|
+
// Flow anomaly — has this key ever made a GET request?
|
|
74
|
+
hasGet: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const NUMERIC_ID_RE = /\/(\d+)(?:\/|$|\?)/u;
|
|
78
|
+
|
|
79
|
+
function makeState(now: number): State {
|
|
80
|
+
return {
|
|
81
|
+
winStart: now, winCount: 0,
|
|
82
|
+
spikeStart: now, spikeCount: 0,
|
|
83
|
+
totalCount: 0,
|
|
84
|
+
lastPathNum: null, seqRun: 0,
|
|
85
|
+
epWinStart: now, epCounts: new Map(),
|
|
86
|
+
reqCount: 0, errCount: 0,
|
|
87
|
+
hasGet: false
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── factory ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export function createEngine(opts: EngineOptions = {}): Engine {
|
|
94
|
+
const velocityThreshold = opts.velocityThreshold ?? 60;
|
|
95
|
+
const velocityWindowMs = opts.velocityWindowMs ?? 60_000;
|
|
96
|
+
const spikeThreshold = opts.spikeThreshold ?? 20;
|
|
97
|
+
const concThreshold = opts.concThreshold ?? 20;
|
|
98
|
+
const errorRateThreshold = opts.errorRateThreshold ?? 0.5;
|
|
99
|
+
const fastActionMs = opts.fastActionMs ?? 3_000;
|
|
100
|
+
|
|
101
|
+
const store = new Map<string, State>();
|
|
102
|
+
|
|
103
|
+
function state(key: string, now: number): State {
|
|
104
|
+
let s = store.get(key);
|
|
105
|
+
if (!s) { s = makeState(now); store.set(key, s); }
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
track(key, event) {
|
|
111
|
+
const now = event.now ?? Date.now();
|
|
112
|
+
const s = state(key, now);
|
|
113
|
+
const reasons: RiskReason[] = [];
|
|
114
|
+
|
|
115
|
+
// ── Velocity (O(1) fixed window) ───────────────────────────────────────
|
|
116
|
+
if (now - s.winStart >= velocityWindowMs) { s.winStart = now; s.winCount = 0; }
|
|
117
|
+
s.winCount++;
|
|
118
|
+
if (s.winCount > velocityThreshold) reasons.push("velocity");
|
|
119
|
+
|
|
120
|
+
// ── Spike (O(1) fixed 10 s window) ────────────────────────────────────
|
|
121
|
+
if (now - s.spikeStart >= 10_000) { s.spikeStart = now; s.spikeCount = 0; }
|
|
122
|
+
s.spikeCount++;
|
|
123
|
+
if (s.spikeCount > spikeThreshold) reasons.push("spike");
|
|
124
|
+
|
|
125
|
+
s.totalCount++;
|
|
126
|
+
|
|
127
|
+
// ── Fast action (submit surface) ───────────────────────────────────────
|
|
128
|
+
if (event.elapsed !== undefined && event.elapsed < fastActionMs) {
|
|
129
|
+
reasons.push("fast_action");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (event.url !== undefined) {
|
|
133
|
+
// ── Access-surface signals ─────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
// Enumeration (O(1) sequential-run counter)
|
|
136
|
+
const m = NUMERIC_ID_RE.exec(event.url);
|
|
137
|
+
if (m) {
|
|
138
|
+
const num = parseInt(m[1], 10);
|
|
139
|
+
s.seqRun = (s.lastPathNum !== null && Math.abs(num - s.lastPathNum) <= 5)
|
|
140
|
+
? s.seqRun + 1
|
|
141
|
+
: 1;
|
|
142
|
+
s.lastPathNum = num;
|
|
143
|
+
} else {
|
|
144
|
+
s.lastPathNum = null;
|
|
145
|
+
s.seqRun = 0;
|
|
146
|
+
}
|
|
147
|
+
if (s.seqRun >= 3) reasons.push("enumeration");
|
|
148
|
+
|
|
149
|
+
// Endpoint concentration (O(1) per-endpoint window counter)
|
|
150
|
+
if (now - s.epWinStart >= velocityWindowMs) { s.epCounts.clear(); s.epWinStart = now; }
|
|
151
|
+
const ep = normalizeEndpoint(event.url);
|
|
152
|
+
const epCount = (s.epCounts.get(ep) ?? 0) + 1;
|
|
153
|
+
s.epCounts.set(ep, epCount);
|
|
154
|
+
if (epCount > concThreshold) reasons.push("endpoint_concentration");
|
|
155
|
+
|
|
156
|
+
// Flow anomaly (O(1) method tracker — POST/PUT/DELETE with no prior GET)
|
|
157
|
+
if (event.method) {
|
|
158
|
+
const method = event.method.toUpperCase();
|
|
159
|
+
if (method === "GET") {
|
|
160
|
+
s.hasGet = true;
|
|
161
|
+
} else if (!s.hasGet && s.totalCount > 2) {
|
|
162
|
+
reasons.push("flow_anomaly");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Probing (O(1) error rate — evaluated on next request after reportError calls)
|
|
167
|
+
s.reqCount++;
|
|
168
|
+
if (s.reqCount >= 5 && s.errCount / s.reqCount > errorRateThreshold) {
|
|
169
|
+
reasons.push("probing");
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
// ── Submit-surface signals ─────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
// Retry (second or later submit to the same form)
|
|
175
|
+
if (s.totalCount > 1) reasons.push("retry");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return reasons;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
reportError(key) {
|
|
182
|
+
const s = store.get(key);
|
|
183
|
+
if (s) { s.errCount++; }
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function normalizeEndpoint(url: string): string {
|
|
191
|
+
// Strip query string, collapse numeric path segments to :id
|
|
192
|
+
return url.split("?")[0].replace(/\/\d+/gu, "/:id");
|
|
193
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { createEngine } from "./engine.js";
|
|
2
|
+
import { buildRiskResult, type RiskReason, type RiskResult } from "./risk.js";
|
|
3
|
+
|
|
4
|
+
export type { RiskReason, RiskLevel, RiskResult } from "./risk.js";
|
|
5
|
+
|
|
6
|
+
type BlockReason =
|
|
7
|
+
| "invalid_email_format"
|
|
8
|
+
| "disposable_email"
|
|
9
|
+
| "invalid_phone_format"
|
|
10
|
+
| "phone_too_short"
|
|
11
|
+
| "phone_too_long"
|
|
12
|
+
| "repeated_digit_phone"
|
|
13
|
+
| "sequential_phone"
|
|
14
|
+
| "reserved_test_phone";
|
|
15
|
+
|
|
16
|
+
type SignupInput = {
|
|
17
|
+
email?: string | null;
|
|
18
|
+
phone?: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ConversionFormIntent =
|
|
22
|
+
| "signup"
|
|
23
|
+
| "checkout"
|
|
24
|
+
| "demo_request"
|
|
25
|
+
| "waitlist"
|
|
26
|
+
| "lead_capture"
|
|
27
|
+
| "contact_sales"
|
|
28
|
+
| "invite";
|
|
29
|
+
|
|
30
|
+
export type WatchFormsEvent = {
|
|
31
|
+
intent: ConversionFormIntent;
|
|
32
|
+
timestamp: string;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type WatchFormsOptions = {
|
|
37
|
+
/** Root node to search. Defaults to document. */
|
|
38
|
+
root?: ParentNode;
|
|
39
|
+
/** Revenue-critical flow being watched. Defaults to signup. */
|
|
40
|
+
intent?: ConversionFormIntent;
|
|
41
|
+
/** Optional metadata copied into the submit event. */
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
/** Called when a watched form submits. */
|
|
44
|
+
onSubmit?: (event: WatchFormsEvent) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type WatchFormsController = {
|
|
48
|
+
/** Number of forms watched by this call. */
|
|
49
|
+
watchedForms: number;
|
|
50
|
+
/** Remove submit listeners added by this call. */
|
|
51
|
+
destroy: () => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ProtectFormsController = {
|
|
55
|
+
/** Number of forms protected by this call. */
|
|
56
|
+
protectedForms: number;
|
|
57
|
+
/** Remove submit listeners added by this call. */
|
|
58
|
+
destroy: () => void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type WatchActionsOptions = {
|
|
62
|
+
/** Action being watched — e.g. "checkout", "reset", "update". */
|
|
63
|
+
action: string;
|
|
64
|
+
/** Root node to search. Defaults to document. */
|
|
65
|
+
root?: ParentNode;
|
|
66
|
+
/** Called on each action with the risk result. */
|
|
67
|
+
onAction?: (event: WatchActionsEvent) => void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type WatchActionsEvent = {
|
|
71
|
+
action: string;
|
|
72
|
+
risk: RiskResult;
|
|
73
|
+
timestamp: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type WatchActionsController = {
|
|
77
|
+
/** Number of forms watched by this call. */
|
|
78
|
+
watchedForms: number;
|
|
79
|
+
/** Remove submit listeners added by this call. */
|
|
80
|
+
destroy: () => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
84
|
+
"10minutemail.com",
|
|
85
|
+
"anonaddy.com",
|
|
86
|
+
"dispostable.com",
|
|
87
|
+
"guerrillamail.com",
|
|
88
|
+
"mailinator.com",
|
|
89
|
+
"maildrop.cc",
|
|
90
|
+
"sharklasers.com",
|
|
91
|
+
"temp-mail.org",
|
|
92
|
+
"tempmail.com",
|
|
93
|
+
"throwawaymail.com",
|
|
94
|
+
"yopmail.com"
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
|
|
98
|
+
const SEQUENTIAL_PHONE_PATTERN = /(?:012345|123456|234567|345678|456789|987654|876543|765432|654321|543210)/u;
|
|
99
|
+
const EMAIL_FIELD_SELECTOR = [
|
|
100
|
+
"input[type='email']",
|
|
101
|
+
"input[name*='email' i]",
|
|
102
|
+
"input[id*='email' i]",
|
|
103
|
+
"input[autocomplete='email' i]"
|
|
104
|
+
].join(", ");
|
|
105
|
+
const PHONE_FIELD_SELECTOR = [
|
|
106
|
+
"input[type='tel']",
|
|
107
|
+
"input[name*='phone' i]",
|
|
108
|
+
"input[id*='phone' i]",
|
|
109
|
+
"input[name*='mobile' i]",
|
|
110
|
+
"input[id*='mobile' i]",
|
|
111
|
+
"input[name*='telephone' i]",
|
|
112
|
+
"input[id*='telephone' i]",
|
|
113
|
+
"input[name*='tel' i]",
|
|
114
|
+
"input[id*='tel' i]",
|
|
115
|
+
"input[autocomplete='tel' i]"
|
|
116
|
+
].join(", ");
|
|
117
|
+
const PHONE_FIELD_WORDS = ["phone", "mobile", "telephone", "tel"];
|
|
118
|
+
const PHONE_FIELD_PHRASES = ["contact number"];
|
|
119
|
+
const FORM_LISTENER_OPTIONS = { capture: true };
|
|
120
|
+
const SIGNUP_GUARD_FORM_ATTRIBUTE = "data-signup-guard-form";
|
|
121
|
+
const SIGNUP_GUARD_IGNORE_ATTRIBUTE = "data-signup-guard-ignore";
|
|
122
|
+
|
|
123
|
+
const protectedFormListeners = new WeakMap<HTMLFormElement, EventListener>();
|
|
124
|
+
const watchedFormListeners = new WeakMap<HTMLFormElement, EventListener>();
|
|
125
|
+
const watchedActionListeners = new WeakMap<HTMLFormElement, EventListener>();
|
|
126
|
+
|
|
127
|
+
export function watchSignups(options: Omit<WatchFormsOptions, "intent"> = {}): WatchFormsController {
|
|
128
|
+
return watchForms({ ...options, intent: "signup" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function watchForms(options: WatchFormsOptions = {}): WatchFormsController {
|
|
132
|
+
const root = options.root ?? document;
|
|
133
|
+
const intent = options.intent ?? "signup";
|
|
134
|
+
const attachedForms: HTMLFormElement[] = [];
|
|
135
|
+
|
|
136
|
+
for (const form of Array.from(root.querySelectorAll<HTMLFormElement>("form"))) {
|
|
137
|
+
if (watchedFormListeners.has(form) || !shouldWatchForm(form, intent)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onSubmit = () => {
|
|
142
|
+
const detail = {
|
|
143
|
+
intent,
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
metadata: options.metadata ?? {}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
options.onSubmit?.(detail);
|
|
149
|
+
form.dispatchEvent(new CustomEvent("signupguard:submit", { detail }));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
153
|
+
watchedFormListeners.set(form, onSubmit);
|
|
154
|
+
attachedForms.push(form);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
watchedForms: attachedForms.length,
|
|
159
|
+
destroy: () => {
|
|
160
|
+
for (const form of attachedForms) {
|
|
161
|
+
const listener = watchedFormListeners.get(form);
|
|
162
|
+
|
|
163
|
+
if (listener) {
|
|
164
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
165
|
+
watchedFormListeners.delete(form);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function watchActions(options: WatchActionsOptions): WatchActionsController {
|
|
173
|
+
const root = options.root ?? document;
|
|
174
|
+
const { action } = options;
|
|
175
|
+
const attachTime = Date.now();
|
|
176
|
+
const attachedForms: HTMLFormElement[] = [];
|
|
177
|
+
|
|
178
|
+
// One engine per watchActions() call — isolated state, O(1) counters per form key.
|
|
179
|
+
const engine = createEngine({ velocityThreshold: 3, velocityWindowMs: 60_000 });
|
|
180
|
+
// Stable string key per form so the engine can track per-form state.
|
|
181
|
+
const formKeys = new Map<HTMLFormElement, string>();
|
|
182
|
+
let nextKey = 0;
|
|
183
|
+
|
|
184
|
+
for (const form of Array.from(root.querySelectorAll<HTMLFormElement>("form"))) {
|
|
185
|
+
if (watchedActionListeners.has(form) || !shouldWatchActionForm(form, action)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const key = String(nextKey++);
|
|
190
|
+
formKeys.set(form, key);
|
|
191
|
+
|
|
192
|
+
const onSubmit = () => {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const reasons = engine.track(key, { now, elapsed: now - attachTime });
|
|
195
|
+
const risk = buildRiskResult(reasons);
|
|
196
|
+
injectRiskField(form, risk);
|
|
197
|
+
|
|
198
|
+
const detail: WatchActionsEvent = { action, risk, timestamp: new Date().toISOString() };
|
|
199
|
+
options.onAction?.(detail);
|
|
200
|
+
form.dispatchEvent(new CustomEvent("signupguard:action", { detail }));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
204
|
+
watchedActionListeners.set(form, onSubmit);
|
|
205
|
+
attachedForms.push(form);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
watchedForms: attachedForms.length,
|
|
210
|
+
destroy: () => {
|
|
211
|
+
for (const form of attachedForms) {
|
|
212
|
+
const listener = watchedActionListeners.get(form);
|
|
213
|
+
|
|
214
|
+
if (listener) {
|
|
215
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
216
|
+
watchedActionListeners.delete(form);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function protectForms(root: ParentNode = document): ProtectFormsController {
|
|
224
|
+
const forms = Array.from(root.querySelectorAll<HTMLFormElement>("form"));
|
|
225
|
+
const attachedForms: HTMLFormElement[] = [];
|
|
226
|
+
|
|
227
|
+
for (const form of forms) {
|
|
228
|
+
if (protectedFormListeners.has(form) || !shouldProtectForm(form)) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const onSubmit = (event: Event) => {
|
|
233
|
+
const fields = detectSignupFields(form);
|
|
234
|
+
|
|
235
|
+
if (!fields) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
clearSignupGuardMessage(fields.email);
|
|
240
|
+
clearSignupGuardMessage(fields.phone);
|
|
241
|
+
|
|
242
|
+
const blocked = getBlockedReason({
|
|
243
|
+
email: fields.email?.value ?? null,
|
|
244
|
+
phone: fields.phone?.value ?? null
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!blocked) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
event.stopImmediatePropagation();
|
|
253
|
+
showSignupGuardMessage(form, fields, blocked);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
form.addEventListener("submit", onSubmit, FORM_LISTENER_OPTIONS);
|
|
257
|
+
protectedFormListeners.set(form, onSubmit);
|
|
258
|
+
attachedForms.push(form);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
protectedForms: attachedForms.length,
|
|
263
|
+
destroy: () => {
|
|
264
|
+
for (const form of attachedForms) {
|
|
265
|
+
const listener = protectedFormListeners.get(form);
|
|
266
|
+
|
|
267
|
+
if (listener) {
|
|
268
|
+
form.removeEventListener("submit", listener, FORM_LISTENER_OPTIONS);
|
|
269
|
+
protectedFormListeners.delete(form);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
function shouldProtectForm(form: HTMLFormElement): boolean {
|
|
278
|
+
return !hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE) &&
|
|
279
|
+
(hasFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE) || Boolean(detectSignupFields(form)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function shouldWatchForm(form: HTMLFormElement, intent: ConversionFormIntent): boolean {
|
|
283
|
+
if (hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE)) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const explicitIntent = getFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE);
|
|
288
|
+
|
|
289
|
+
if (explicitIntent !== null) {
|
|
290
|
+
return explicitIntent === "" || explicitIntent === "true" || explicitIntent === intent;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (intent === "signup") {
|
|
294
|
+
return Boolean(detectSignupFields(form));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return formText(form).includes(intent.replace("_", " ")) ||
|
|
298
|
+
intentKeywords(intent).some((keyword) => formText(form).includes(keyword));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function shouldWatchActionForm(form: HTMLFormElement, action: string): boolean {
|
|
302
|
+
if (hasFormAttribute(form, SIGNUP_GUARD_IGNORE_ATTRIBUTE)) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const explicitIntent = getFormAttribute(form, SIGNUP_GUARD_FORM_ATTRIBUTE);
|
|
307
|
+
|
|
308
|
+
if (explicitIntent !== null) {
|
|
309
|
+
return explicitIntent === "" || explicitIntent === "true" || explicitIntent === action;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const normalized = action.replace(/_/g, " ").toLowerCase();
|
|
313
|
+
return formText(form).includes(normalized) ||
|
|
314
|
+
intentKeywords(action as ConversionFormIntent).some((kw) => formText(form).includes(kw));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hasFormAttribute(form: HTMLFormElement, name: string): boolean {
|
|
318
|
+
return typeof form.hasAttribute === "function" && form.hasAttribute(name);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getFormAttribute(form: HTMLFormElement, name: string): string | null {
|
|
322
|
+
if (!hasFormAttribute(form, name) || typeof form.getAttribute !== "function") {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return form.getAttribute(name);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formText(form: HTMLFormElement): string {
|
|
330
|
+
return [
|
|
331
|
+
form.getAttribute?.("id"),
|
|
332
|
+
form.getAttribute?.("name"),
|
|
333
|
+
form.getAttribute?.("aria-label"),
|
|
334
|
+
form.getAttribute?.(SIGNUP_GUARD_FORM_ATTRIBUTE),
|
|
335
|
+
form.textContent
|
|
336
|
+
]
|
|
337
|
+
.filter(Boolean)
|
|
338
|
+
.join(" ")
|
|
339
|
+
.toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function intentKeywords(intent: ConversionFormIntent): string[] {
|
|
343
|
+
if (intent === "checkout") return ["checkout", "payment", "billing", "card"];
|
|
344
|
+
if (intent === "demo_request") return ["demo", "book a demo", "request demo"];
|
|
345
|
+
if (intent === "waitlist") return ["waitlist", "join waitlist"];
|
|
346
|
+
if (intent === "lead_capture") return ["lead", "contact", "work email"];
|
|
347
|
+
if (intent === "contact_sales") return ["contact sales", "sales"];
|
|
348
|
+
if (intent === "invite") return ["invite", "invitation"];
|
|
349
|
+
|
|
350
|
+
return ["signup", "sign up", "create account"];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function injectRiskField(form: HTMLFormElement, risk: RiskResult): void {
|
|
354
|
+
const doc = form.ownerDocument;
|
|
355
|
+
if (!doc) return;
|
|
356
|
+
|
|
357
|
+
const FIELD_NAME = "signupGuardRisk";
|
|
358
|
+
let field = form.querySelector<HTMLInputElement>(`input[name="${FIELD_NAME}"]`);
|
|
359
|
+
|
|
360
|
+
if (!field) {
|
|
361
|
+
field = doc.createElement("input");
|
|
362
|
+
field.type = "hidden";
|
|
363
|
+
field.name = FIELD_NAME;
|
|
364
|
+
form.appendChild(field);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
field.value = JSON.stringify(risk);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type DetectedSignupFields = {
|
|
371
|
+
email?: HTMLInputElement;
|
|
372
|
+
phone?: HTMLInputElement;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
function detectSignupFields(form: HTMLFormElement): DetectedSignupFields | null {
|
|
376
|
+
const email = findEmailField(form);
|
|
377
|
+
const phone = findPhoneField(form);
|
|
378
|
+
|
|
379
|
+
if (!email && !phone) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { email, phone };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function findEmailField(form: HTMLFormElement): HTMLInputElement | undefined {
|
|
387
|
+
return form.querySelector<HTMLInputElement>(EMAIL_FIELD_SELECTOR) ?? undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function findPhoneField(form: HTMLFormElement): HTMLInputElement | undefined {
|
|
391
|
+
const directMatch = form.querySelector<HTMLInputElement>(PHONE_FIELD_SELECTOR);
|
|
392
|
+
|
|
393
|
+
if (directMatch) {
|
|
394
|
+
return directMatch;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return Array.from(form.querySelectorAll<HTMLInputElement>("input")).find((field) => isPhoneField(field));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isPhoneField(field: HTMLInputElement): boolean {
|
|
401
|
+
const text = fieldText(field);
|
|
402
|
+
|
|
403
|
+
return PHONE_FIELD_WORDS.some((word) => text.includes(word)) ||
|
|
404
|
+
PHONE_FIELD_PHRASES.some((phrase) => text.includes(phrase));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function fieldText(field: HTMLInputElement): string {
|
|
408
|
+
const labels = Array.from(field.labels ?? [], (label) => label.textContent ?? "");
|
|
409
|
+
|
|
410
|
+
return [
|
|
411
|
+
field.name,
|
|
412
|
+
field.id,
|
|
413
|
+
field.type,
|
|
414
|
+
field.autocomplete,
|
|
415
|
+
field.placeholder,
|
|
416
|
+
field.getAttribute("aria-label"),
|
|
417
|
+
...labels
|
|
418
|
+
]
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.join(" ")
|
|
421
|
+
.toLowerCase();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getBlockedReason(input: SignupInput): BlockReason | null {
|
|
425
|
+
const email = String(input.email ?? "").trim().toLowerCase();
|
|
426
|
+
const phone = String(input.phone ?? "").trim();
|
|
427
|
+
|
|
428
|
+
if (email) {
|
|
429
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
430
|
+
return "invalid_email_format";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (isDisposableEmail(email)) {
|
|
434
|
+
return "disposable_email";
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!phone) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const digits = phone.replace(/\D/gu, "");
|
|
443
|
+
const phoneFormatReason = getPhoneFormatReason(phone, digits);
|
|
444
|
+
|
|
445
|
+
if (phoneFormatReason) {
|
|
446
|
+
return phoneFormatReason;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return getFakePhoneReason(digits);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function isDisposableEmail(email: string): boolean {
|
|
453
|
+
const domain = String(email).trim().toLowerCase().split("@").pop();
|
|
454
|
+
|
|
455
|
+
return Boolean(domain && DISPOSABLE_EMAIL_DOMAINS.has(domain));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function getPhoneFormatReason(phone: string, digits: string): BlockReason | null {
|
|
459
|
+
const trimmed = phone.trim();
|
|
460
|
+
|
|
461
|
+
if (!/^[+()\-.\s\d]+$/u.test(trimmed)) return "invalid_phone_format";
|
|
462
|
+
if (digits.length < 7) return "phone_too_short";
|
|
463
|
+
if (digits.length > 15) return "phone_too_long";
|
|
464
|
+
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function getFakePhoneReason(digits: string): BlockReason | null {
|
|
469
|
+
if (/^(\d)\1+$/u.test(digits)) return "repeated_digit_phone";
|
|
470
|
+
if (SEQUENTIAL_PHONE_PATTERN.test(digits)) return "sequential_phone";
|
|
471
|
+
if (/^55501\d{2}$/u.test(digits.slice(-7)) || /^555010\d$/u.test(digits.slice(-7))) return "reserved_test_phone";
|
|
472
|
+
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function showSignupGuardMessage(
|
|
477
|
+
form: HTMLFormElement,
|
|
478
|
+
fields: DetectedSignupFields,
|
|
479
|
+
reason: BlockReason
|
|
480
|
+
): void {
|
|
481
|
+
const field = reason.includes("email") ? fields.email : fields.phone;
|
|
482
|
+
const message = messageForReason(reason);
|
|
483
|
+
|
|
484
|
+
if (field && "setCustomValidity" in field && "reportValidity" in field) {
|
|
485
|
+
field.setCustomValidity(message);
|
|
486
|
+
field.reportValidity();
|
|
487
|
+
field.addEventListener("input", () => clearSignupGuardMessage(field), { once: true });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
form.dispatchEvent(new CustomEvent("signupguard:block", { detail: { reason, message } }));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function clearSignupGuardMessage(field?: HTMLInputElement): void {
|
|
494
|
+
field?.setCustomValidity("");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function messageForReason(reason: BlockReason): string {
|
|
498
|
+
if (reason === "invalid_email_format") {
|
|
499
|
+
return "Please enter a valid email address.";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (reason === "disposable_email") {
|
|
503
|
+
return "This email provider is not supported.";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return "Please enter a valid phone number.";
|
|
507
|
+
}
|