backend-manager 5.0.186 → 5.0.187
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/CLAUDE.md +52 -0
- package/package.json +3 -1
- package/scripts/update-disposable-domains.js +44 -0
- package/src/manager/events/auth/before-create.js +12 -3
- package/src/manager/helpers/middleware.js +36 -16
- package/src/manager/helpers/settings.js +2 -0
- package/src/manager/helpers/utilities.js +40 -0
- package/src/manager/libraries/custom-disposable-domains.json +12 -0
- package/src/manager/libraries/disposable-domains.json +4580 -75
- package/src/manager/libraries/email/validation.js +26 -3
- package/src/manager/libraries/infer-contact.js +4 -54
- package/src/manager/routes/user/signup/post.js +9 -3
- package/src/manager/schemas/admin/email/post.js +1 -1
- package/src/test/test-accounts.js +9 -0
- package/test/functions/general/add-marketing-contact.js +2 -1
- package/test/functions/user/sign-up.js +38 -0
- package/test/helpers/email-validation.js +54 -1
- package/test/helpers/infer-contact.js +17 -195
- package/test/helpers/sanitize.js +222 -0
- package/test/routes/marketing/contact.js +2 -1
- package/test/routes/user/signup.js +38 -0
package/CLAUDE.md
CHANGED
|
@@ -207,6 +207,58 @@ require(functionsDir + '/node_modules/backend-manager')
|
|
|
207
207
|
### Prefer fs-jetpack
|
|
208
208
|
Use `fs-jetpack` over `fs` or `fs-extra` for file operations.
|
|
209
209
|
|
|
210
|
+
## Sanitization (XSS Prevention)
|
|
211
|
+
|
|
212
|
+
BEM automatically sanitizes all incoming request data — stripping HTML tags and trimming whitespace from every string field. This happens in the middleware pipeline before route handlers execute, so **routes receive clean data by default**.
|
|
213
|
+
|
|
214
|
+
### How It Works
|
|
215
|
+
1. **Schema fields**: Sanitized per-field during the middleware pipeline. Fields can opt out with `sanitize: false` in the schema.
|
|
216
|
+
2. **Non-schema fields** (when `setupSettings: false` or `includeNonSchemaSettings: true`): All strings are sanitized with no opt-out.
|
|
217
|
+
3. The middleware uses `Manager.Utilities().sanitize()` under the hood.
|
|
218
|
+
|
|
219
|
+
### Schema Opt-Out
|
|
220
|
+
For fields that legitimately need HTML (rich text, email templates, etc.), set `sanitize: false` in the schema:
|
|
221
|
+
```javascript
|
|
222
|
+
// This field will NOT be sanitized — raw HTML is preserved
|
|
223
|
+
htmlContent: {
|
|
224
|
+
types: ['string'],
|
|
225
|
+
default: '',
|
|
226
|
+
sanitize: false,
|
|
227
|
+
},
|
|
228
|
+
// This field IS sanitized (default behavior, no flag needed)
|
|
229
|
+
name: {
|
|
230
|
+
types: ['string'],
|
|
231
|
+
default: '',
|
|
232
|
+
},
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Route-Level Opt-Out
|
|
236
|
+
Disable sanitization entirely for a route (rare — only for routes that handle raw HTML everywhere):
|
|
237
|
+
```javascript
|
|
238
|
+
// In functions/index.js
|
|
239
|
+
Manager.Middleware(req, res).run('my-route', { sanitize: false });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Manual Sanitization (Outside Middleware)
|
|
243
|
+
For cron jobs, event handlers, or anywhere outside the request pipeline, use `utilities.sanitize()` directly:
|
|
244
|
+
```javascript
|
|
245
|
+
// Available in route context
|
|
246
|
+
const clean = utilities.sanitize(untrustedData);
|
|
247
|
+
|
|
248
|
+
// Or via Manager
|
|
249
|
+
const clean = Manager.Utilities().sanitize(untrustedData);
|
|
250
|
+
```
|
|
251
|
+
Accepts any data type — strings, objects, arrays, primitives. Walks objects/arrays recursively, strips HTML from strings, passes everything else through unchanged.
|
|
252
|
+
|
|
253
|
+
### Route Handler Context
|
|
254
|
+
The middleware injects these into every route handler:
|
|
255
|
+
```javascript
|
|
256
|
+
module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
|
|
257
|
+
// settings — already sanitized by middleware
|
|
258
|
+
// utilities — Manager.Utilities() instance for manual sanitization
|
|
259
|
+
};
|
|
260
|
+
```
|
|
261
|
+
|
|
210
262
|
## Creating New Components
|
|
211
263
|
|
|
212
264
|
### New API Command
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.187",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "node src/manager/index.js",
|
|
14
|
+
"prepublishOnly": "node scripts/update-disposable-domains.js",
|
|
14
15
|
"prepare": "node -e \"require('prepare-package')()\"",
|
|
15
16
|
"prepare:watch": "node -e \"require('prepare-package/watch')()\""
|
|
16
17
|
},
|
|
@@ -78,6 +79,7 @@
|
|
|
78
79
|
"npm-api": "^1.0.1",
|
|
79
80
|
"paypal-server-api": "^2.0.14",
|
|
80
81
|
"pushid": "^1.0.0",
|
|
82
|
+
"sanitize-html": "^2.17.2",
|
|
81
83
|
"shortid": "^2.2.17",
|
|
82
84
|
"stripe": "^20.3.1",
|
|
83
85
|
"uid-generator": "^2.0.0",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetches the latest disposable email domain list from GitHub and saves it as JSON.
|
|
5
|
+
*
|
|
6
|
+
* Source: https://github.com/disposable-email-domains/disposable-email-domains
|
|
7
|
+
* (curated, ~5k domains, low false-positive rate)
|
|
8
|
+
*
|
|
9
|
+
* Run manually: node scripts/update-disposable-domains.js
|
|
10
|
+
* Runs automatically on: npm prepublishOnly
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const SOURCE_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
|
16
|
+
const OUTPUT_PATH = path.join(__dirname, '..', 'src', 'manager', 'libraries', 'disposable-domains.json');
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
console.log('Fetching disposable domain list...');
|
|
20
|
+
|
|
21
|
+
const response = await fetch(SOURCE_URL);
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
const domains = text
|
|
29
|
+
.trim()
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map(d => d.trim().toLowerCase())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
|
|
34
|
+
const unique = [...new Set(domains)].sort();
|
|
35
|
+
|
|
36
|
+
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(unique, null, 2) + '\n');
|
|
37
|
+
|
|
38
|
+
console.log(`Updated disposable-domains.json: ${unique.length} domains`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main().catch((e) => {
|
|
42
|
+
console.error('Failed to update disposable domains:', e.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
const
|
|
1
|
+
const { isDisposable } = require('../../libraries/email/validation.js');
|
|
2
|
+
|
|
3
|
+
const ERROR_TOO_MANY_ATTEMPTS = 'Unable to create account at this time. Please try again later.';
|
|
4
|
+
const ERROR_DISPOSABLE_EMAIL = 'This email domain is not allowed. Please use a different email address.';
|
|
2
5
|
const MAX_SIGNUPS_PER_DAY = 2;
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
|
-
* beforeUserCreated - IP
|
|
8
|
+
* beforeUserCreated - Disposable email blocking + IP rate limiting
|
|
6
9
|
*
|
|
7
|
-
* This function ONLY handles IP rate limiting to prevent abuse.
|
|
8
10
|
* User doc creation is handled by on-create.js (which fires for all user creations including Admin SDK).
|
|
9
11
|
*
|
|
10
12
|
* Why not create user doc here?
|
|
@@ -18,6 +20,13 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
18
20
|
|
|
19
21
|
assistant.log(`beforeCreate: ${user.uid}`, { email: user.email, ip: ipAddress });
|
|
20
22
|
|
|
23
|
+
// Block disposable email domains
|
|
24
|
+
if (isDisposable(user.email)) {
|
|
25
|
+
assistant.error(`beforeCreate: Blocked disposable email ${user.email}`);
|
|
26
|
+
|
|
27
|
+
throw new functions.auth.HttpsError('invalid-argument', ERROR_DISPOSABLE_EMAIL);
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
// Skip rate limiting if no IP (shouldn't happen in production)
|
|
22
31
|
if (!ipAddress) {
|
|
23
32
|
assistant.log(`beforeCreate: No IP address, skipping rate limit check (${Date.now() - startTime}ms)`);
|
|
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
35
35
|
options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
|
|
36
36
|
options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
|
|
37
37
|
options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
|
|
38
|
-
options.
|
|
38
|
+
options.sanitize = typeof options.sanitize === 'undefined' ? true : options.sanitize;
|
|
39
39
|
options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
|
|
40
40
|
options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
|
|
41
41
|
options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
|
|
@@ -168,14 +168,6 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
168
168
|
assistant.settings = merge(data, assistant.settings)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// Clean settings by looping through and trimming all strings
|
|
172
|
-
if (options.cleanSettings) {
|
|
173
|
-
clean(assistant.settings);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Log
|
|
177
|
-
assistant.log(`Middleware.process(): Resolved settings with schema=${options.schema}`, safeStringify(assistant.settings));
|
|
178
|
-
|
|
179
171
|
// Log multipart files if they exist
|
|
180
172
|
const files = assistant.request.multipartData.files || {};
|
|
181
173
|
if (files) {
|
|
@@ -185,6 +177,19 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
185
177
|
assistant.settings = data;
|
|
186
178
|
}
|
|
187
179
|
|
|
180
|
+
// Sanitize settings — trim whitespace and strip HTML from all strings
|
|
181
|
+
// Respects sanitize: false on individual schema fields
|
|
182
|
+
if (options.sanitize) {
|
|
183
|
+
const schema = options.setupSettings ? Manager.Settings().schema : null;
|
|
184
|
+
const utilities = Manager.Utilities();
|
|
185
|
+
|
|
186
|
+
// Walk settings, skipping fields the schema marks as sanitize: false
|
|
187
|
+
assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Log
|
|
191
|
+
assistant.log(`Middleware.process(): Resolved settings with schema=${options.schema}`, safeStringify(assistant.settings));
|
|
192
|
+
|
|
188
193
|
// Build context object for route handler
|
|
189
194
|
const context = {
|
|
190
195
|
Manager: Manager,
|
|
@@ -194,6 +199,7 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
194
199
|
settings: assistant.settings,
|
|
195
200
|
analytics: assistant.analytics,
|
|
196
201
|
libraries: Manager.libraries,
|
|
202
|
+
utilities: Manager.Utilities(),
|
|
197
203
|
};
|
|
198
204
|
|
|
199
205
|
// Execute route handler
|
|
@@ -208,15 +214,29 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
208
214
|
});
|
|
209
215
|
};
|
|
210
216
|
|
|
211
|
-
function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
function sanitizeWithSchema(utilities, obj, schema) {
|
|
218
|
+
// Not an object — sanitize directly
|
|
219
|
+
if (obj == null || typeof obj !== 'object') {
|
|
220
|
+
return utilities.sanitize(obj);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Arrays — sanitize each item (no schema for array items)
|
|
224
|
+
if (Array.isArray(obj)) {
|
|
225
|
+
return obj.map(item => utilities.sanitize(item));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Objects — walk keys, skip fields where schema says sanitize: false
|
|
229
|
+
const result = {};
|
|
230
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
231
|
+
const schemaNode = schema ? schema[key] : null;
|
|
232
|
+
|
|
233
|
+
if (schemaNode && schemaNode.sanitize === false) {
|
|
234
|
+
result[key] = value;
|
|
235
|
+
} else {
|
|
236
|
+
result[key] = sanitizeWithSchema(utilities, value, schemaNode);
|
|
218
237
|
}
|
|
219
238
|
}
|
|
239
|
+
return result;
|
|
220
240
|
}
|
|
221
241
|
|
|
222
242
|
function stripUrl(url) {
|
|
@@ -8,6 +8,7 @@ const powertools = require('node-powertools');
|
|
|
8
8
|
const _ = require('lodash');
|
|
9
9
|
const moment = require('moment');
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
function Settings(m) {
|
|
12
13
|
const self = this;
|
|
13
14
|
|
|
@@ -129,6 +130,7 @@ Settings.prototype.resolve = function (assistant, schema, settings, options) {
|
|
|
129
130
|
available: typeof schemaNode.available === 'undefined' ? true : schemaNode.available,
|
|
130
131
|
min: typeof schemaNode.min === 'undefined' ? undefined : schemaNode.min,
|
|
131
132
|
max: typeof schemaNode.max === 'undefined' ? undefined : schemaNode.max,
|
|
133
|
+
sanitize: typeof schemaNode.sanitize === 'undefined' ? true : schemaNode.sanitize,
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
// Update schema
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
let nanoId;
|
|
2
2
|
let _;
|
|
3
|
+
let sanitizeHtml;
|
|
3
4
|
|
|
4
5
|
function Utilities(Manager) {
|
|
5
6
|
const self = this;
|
|
@@ -467,4 +468,43 @@ Utilities.prototype.get = function (docPath, options) {
|
|
|
467
468
|
});
|
|
468
469
|
};
|
|
469
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Sanitize input by stripping HTML tags and trimming strings.
|
|
473
|
+
* Accepts any data type — walks objects/arrays recursively.
|
|
474
|
+
*
|
|
475
|
+
* @param {*} input - The data to sanitize (string, object, array, or primitive)
|
|
476
|
+
* @returns {*} Sanitized copy (objects/arrays) or sanitized value (strings)
|
|
477
|
+
*/
|
|
478
|
+
Utilities.prototype.sanitize = function (input) {
|
|
479
|
+
// Handle null/undefined
|
|
480
|
+
if (input == null) {
|
|
481
|
+
return input;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Handle strings
|
|
485
|
+
if (typeof input === 'string') {
|
|
486
|
+
sanitizeHtml = sanitizeHtml
|
|
487
|
+
? sanitizeHtml
|
|
488
|
+
: require('sanitize-html');
|
|
489
|
+
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }).trim();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Handle arrays
|
|
493
|
+
if (Array.isArray(input)) {
|
|
494
|
+
return input.map(item => this.sanitize(item));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Handle objects
|
|
498
|
+
if (typeof input === 'object') {
|
|
499
|
+
const result = {};
|
|
500
|
+
for (const [key, value] of Object.entries(input)) {
|
|
501
|
+
result[key] = this.sanitize(value);
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Numbers, booleans, etc. — pass through
|
|
507
|
+
return input;
|
|
508
|
+
};
|
|
509
|
+
|
|
470
510
|
module.exports = Utilities;
|