Skip to content

Commit 503bb1e

Browse files
signup validator and password reset mfa validator (#92)
* adding signup validator for info leakage * fixing eslint issue * adding password reset mfa validator * Update checkPasswordResetMFA.js * Update en.json
1 parent 67380ad commit 503bb1e

File tree

6 files changed

+521
-2
lines changed

6 files changed

+521
-2
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
const _ = require("lodash");
2+
const executeCheck = require("../executeCheck");
3+
const CONSTANTS = require("../constants");
4+
const acorn = require("acorn");
5+
const walk = require("estree-walker").walk;
6+
7+
/**
8+
* Scans the Action code for calls to MFA challenge methods.
9+
* Returns boolean if challenge is found.
10+
*/
11+
function hasMFAChallenge(code, scriptName) {
12+
let challengeFound = false;
13+
let ast;
14+
15+
try {
16+
ast = acorn.parse(code || "", {
17+
ecmaVersion: "latest",
18+
locations: true,
19+
});
20+
} catch (e) {
21+
if (e instanceof SyntaxError) {
22+
console.error(`[ACORN PARSE ERROR] Skipping script "${scriptName}" due to malformed code`);
23+
return [];
24+
}
25+
throw e;
26+
}
27+
28+
walk(ast, {
29+
enter(node) {
30+
if (node.type === "CallExpression") {
31+
const callee = node.callee;
32+
33+
function getMemberExpressionPath(expr) {
34+
if (expr.type === "Identifier") return expr.name;
35+
if (expr.type === "MemberExpression") {
36+
const obj = getMemberExpressionPath(expr.object);
37+
const prop = expr.property.name;
38+
return obj ? `${obj}.${prop}` : prop;
39+
}
40+
return null;
41+
}
42+
43+
const path = getMemberExpressionPath(callee);
44+
if (path === "api.authentication.challengeWith" || path === "api.authentication.challengeWithAny") {
45+
challengeFound = true;
46+
}
47+
}
48+
},
49+
});
50+
51+
return challengeFound;
52+
}
53+
54+
/**
55+
* Main validator for Actions targeting the password-reset trigger.
56+
*/
57+
function checkPasswordResetMFA(options) {
58+
const { actions, databases } = options || {};
59+
60+
return executeCheck("checkPasswordResetMFA", (callback) => {
61+
// 1. Check for Active Password DBs (Same as before)
62+
const hasActivePasswordDb = _.some(databases, (db) => {
63+
const authMethods = db.options?.authentication_methods;
64+
return db.strategy === "auth0" && (!authMethods || authMethods.password?.enabled !== false);
65+
});
66+
67+
if (!hasActivePasswordDb) return callback([]);
68+
69+
const actionsList = _.isArray(actions) ? actions : actions.actions;
70+
if (_.isEmpty(actionsList)) {
71+
return callback([{ name: "Actions", report: [{ field: "no_actions_configured", status: CONSTANTS.WARN }] }]);
72+
}
73+
74+
// 2. Aggregate scan across ALL relevant actions
75+
let passwordResetActionsFound = [];
76+
let anyActionHasMFA = false;
77+
78+
for (const action of actionsList) {
79+
const triggers = action.supported_triggers || [];
80+
const isPassReset = triggers.some(t => t.id === "password-reset-post-challenge");
81+
if (isPassReset) { // deployed_version.deployed
82+
passwordResetActionsFound.push(action.name);
83+
if (hasMFAChallenge(action.code, action.name)) {
84+
anyActionHasMFA = true;
85+
}
86+
}
87+
}
88+
89+
const reports = [];
90+
const flowName = "Password Reset Flow";
91+
92+
// 3. Logic for the single finding report
93+
if (passwordResetActionsFound.length === 0) {
94+
reports.push({
95+
name: "Password Reset Flow",
96+
report: [{
97+
scriptName: flowName,
98+
name: flowName,
99+
status: CONSTANTS.WARN,
100+
variableName: "api.authentication.challengeWith",
101+
line: "N/A",
102+
column: "N/A",
103+
field: "no_password_reset_action",
104+
}]
105+
});
106+
} else if (!anyActionHasMFA) {
107+
// NONE of the password reset actions had MFA
108+
reports.push({
109+
name: "Password Reset Flow",
110+
report: [{
111+
scriptName: `${passwordResetActionsFound.join(", ")}`,
112+
status: CONSTANTS.WARN,
113+
name: flowName,
114+
variableName: "api.authentication.challengeWith",
115+
line: "N/A",
116+
column: "N/A",
117+
field: "missing_mfa_step",
118+
}]
119+
});
120+
} else {
121+
// found an MFA challenge on password reset
122+
}
123+
124+
return callback(reports);
125+
});
126+
}
127+
128+
module.exports = checkPasswordResetMFA;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
const _ = require("lodash");
2+
const executeCheck = require("../executeCheck");
3+
const CONSTANTS = require("../constants");
4+
const acorn = require("acorn");
5+
const walk = require("estree-walker").walk;
6+
7+
/**
8+
* Scans the Action code for calls to api.access.deny()
9+
* to warn about potential user enumeration vulnerabilities.
10+
*/
11+
function detectAccessDeny(code, scriptName) {
12+
const findings = [];
13+
14+
let ast;
15+
try {
16+
ast = acorn.parse(code || "", {
17+
ecmaVersion: "latest",
18+
locations: true,
19+
});
20+
} catch (e) {
21+
if (e instanceof SyntaxError) {
22+
console.error(`[ACORN PARSE ERROR] Skipping script "${scriptName}" due to malformed code`);
23+
return [];
24+
}
25+
throw e;
26+
}
27+
28+
walk(ast, {
29+
enter(node) {
30+
if (node.type === "CallExpression") {
31+
const callee = node.callee;
32+
33+
// Helper function to reconstruct the property chain (e.g., "api.access.deny")
34+
function getMemberExpressionPath(expr) {
35+
if (expr.type === "Identifier") return expr.name;
36+
if (expr.type === "MemberExpression") {
37+
const obj = getMemberExpressionPath(expr.object);
38+
const prop = expr.property.name;
39+
return obj ? `${obj}.${prop}` : prop;
40+
}
41+
return null;
42+
}
43+
44+
const path = getMemberExpressionPath(callee);
45+
46+
// This will now catch api.access.deny regardless of how Acorn nests the objects
47+
if (path === "api.access.deny") {
48+
findings.push({
49+
scriptName: scriptName,
50+
field: "user_enumeration_vulnerability",
51+
status: CONSTANTS.WARN,
52+
line: node.loc?.start?.line || "N/A",
53+
column: node.loc?.start?.column || "N/A",
54+
// We add this to match the grouping logic in report.js
55+
variableName: "api.access.deny"
56+
});
57+
}
58+
}
59+
},
60+
});
61+
62+
return findings;
63+
}
64+
65+
/**
66+
* Main validator for Actions targeting the pre-user-registration trigger.
67+
*/
68+
function checkPreRegistrationUserEnumeration(options) {
69+
const { actions } = options || [];
70+
71+
return executeCheck("checkPreRegistrationUserEnumeration", (callback) => {
72+
const actionsList = _.isArray(actions) ? actions : actions.actions;
73+
const reports = [];
74+
75+
if (_.isEmpty(actionsList)) {
76+
return callback(reports);
77+
}
78+
79+
for (const action of actionsList) {
80+
const triggers = action.supported_triggers || [];
81+
82+
const isPreReg = triggers.some(t => t.id === "pre-user-registration");
83+
// Only scan if the action is part of the pre-user-registration trigger
84+
if (!isPreReg) continue;
85+
86+
const actionName = `${action.name} (pre-user-registration)`;
87+
88+
try {
89+
const findings = detectAccessDeny(action.code, actionName);
90+
if (findings.length > 0) {
91+
reports.push({ name: actionName, report: findings });
92+
}
93+
} catch (e) {
94+
console.error(`[CHECK ERROR] Skipping Actions "${actionName}" due to error: ${e.message}`);
95+
continue;
96+
}
97+
}
98+
return callback(reports);
99+
});
100+
}
101+
102+
module.exports = checkPreRegistrationUserEnumeration;

analyzer/report.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ async function generateReport(locale, tenantConfig, config) {
307307
});
308308
});
309309
break;
310+
case "checkPasswordResetMFA":
311+
case "checkPreRegistrationUserEnumeration":
310312
case "checkActionsHardCodedValues":
311313
case "checkDASHardCodedValues":
312314
report.disclaimer = i18n.__(`${report.name}.disclaimer`);

locales/en.json

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@
135135
"items": [
136136
"NPM Dependencies",
137137
"Actions Runtime",
138-
"Hardcoded Artifacts"
138+
"Hardcoded Artifacts",
139+
"Information Leakage in Signup Flow",
140+
"MFA for Password Reset"
139141
]
140142
},
141143
{
@@ -1369,6 +1371,67 @@
13691371
"hard_coded_value_detected": "Variable name <b>%s</b> at line <b>%d</b> and column <b>%d</b>.",
13701372
"action_script_title": "Identified potential hardcoded credentials in <b>\"%s\"</b> script at:"
13711373
},
1374+
"checkPreRegistrationUserEnumeration": {
1375+
"title": "Information Leakage in Signup Flow",
1376+
"category": "Actions",
1377+
"advisory": {
1378+
"issue": "Exposing sensitive information during a signup attempt",
1379+
"description": {
1380+
"what_it_is": "User enumeration occurs when an application reveals whether a user exists in the system based on the response provided during actions like registration.",
1381+
"why_its_risky": [
1382+
"Using 'api.access.deny' in a Pre-User Registration Action to block signups from existing accounts may allow attackers to programmatically 'scrape' or verify which of your customers have accounts.",
1383+
"If a Pre-User Registration Action is rejecting a signup attempt based on internal risk scoring then threat actors may leverage this information to tune their methods to evade detection.",
1384+
"Disclosing account existance or or exposing risk scoring approaches in the Action, enables threat actors to more easily perform targeted attacks like phishing and credential stuffing, "
1385+
]
1386+
},
1387+
"how_to_fix": [
1388+
"Avoid using 'api.access.deny' in Pre-Registration Actions to notify users that an account is already registered or to notify them that risk level of the request was assessed to be too great.",
1389+
"All registration attempts should receive a generic success response. If an account already exists, consider triggering a secure, automated email to the registered address informing the user of the attempt. This maintains a seamless user experience while protecting account privacy."
1390+
]
1391+
},
1392+
"description": "Analyzes Pre-User Registration Actions for the use of 'api.access.deny', which can leak information or enable user enumeration.",
1393+
"docsPath": [
1394+
"https://auth0.com/docs/customize/actions/action-coding-guidelines"
1395+
],
1396+
"severity": "Low",
1397+
"status": "green",
1398+
"severity_message": "Potential user enumeration detected in a Pre User Registration Action.",
1399+
"user_enumeration_vulnerability": "Found <b>%s</b> at line <b>%d</b>, column <b>%d</b> which may leak information.",
1400+
"action_script_title": "Identified usage of api.access.deny in <b>\"%s\"</b> Action script:",
1401+
"disclaimer": "Please review the Action to determine if any information is exposed that may be useful to a threat actor."
1402+
},
1403+
"checkPasswordResetMFA": {
1404+
"title": "MFA for Password Reset",
1405+
"category": "Actions",
1406+
"advisory": {
1407+
"issue": "Missing MFA Challenge during Password Reset",
1408+
"description": {
1409+
"what_it_is": "This check verifies if your Password Reset Post Challenge Action requires a second factor before allowing a user to change their password.",
1410+
"why_its_risky": [
1411+
"If an attacker gains access to a user's email account, they can trigger a password reset and take over the identity without any further verification.",
1412+
"MFA during password reset ensures that even with a compromised email, the attacker has to overcome the secondary factor challenge."
1413+
]
1414+
},
1415+
"how_to_fix": [
1416+
"Create or update an Action in the Password Reset Post Challenge flow.",
1417+
"Use 'api.authentication.challengeWith()' to trigger an MFA challenge for users who have enrolled factors.",
1418+
"Ensure the Action is 'Deployed' and attached to the Password Reset trigger in the Auth0 Pipeline."
1419+
]
1420+
},
1421+
"description": "Analyzes Password Reset Actions to ensure MFA is being enforced as an additional security layer.",
1422+
"docsPath": [
1423+
"https://auth0.com/docs/customize/actions/explore-triggers/password-reset-triggers"
1424+
],
1425+
"severity": "Moderate",
1426+
"status": "yellow",
1427+
"severity_message": "an MFA Challenge is not detected in the Password Reset flow.",
1428+
"mfa_challenge_detected": "MFA Challenge (<b>%s</b>) correctly implemented at line <b>%d</b>.",
1429+
"missing_mfa_step": "Action script exists but does not appear to trigger an MFA challenge.",
1430+
"no_password_reset_action": "No Action is currently configured for the Password Reset trigger. Password resets are only protected by email access.",
1431+
"no_actions_configured": "No Actions found in tenant.",
1432+
"disclaimer": "This finding may be a false positive if Identity Proofing or similar mitigation is enforced elsewhere in the authentication or password reset workflow. Verify any alternative security controls are active before dismissing this recommendation.",
1433+
"action_script_title": "A deployed Password Reset Action that triggers an MFA challenge was not found based on searching for the 'api.authentication.challengeWith()' or equivalent method."
1434+
},
13721435
"checkCanonicalDomain": {
13731436
"title": "Auth0 Domain Check",
13741437
"category": "Canonical Domain",
@@ -1414,4 +1477,4 @@
14141477
"https://auth0.com/docs/customize/events/event-testing-observability-and-failure-recovery"
14151478
]
14161479
}
1417-
}
1480+
}

0 commit comments

Comments
 (0)