backend-manager 5.0.185 → 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/CHANGELOG.md +4 -0
- package/CLAUDE.md +52 -0
- package/TODO-2.md +16 -0
- package/TODO-email-auth.md +14 -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/transactional/index.js +24 -24
- 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/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.186] - 2026-04-01
|
|
18
|
+
### Fixed
|
|
19
|
+
- Move markdown rendering and UTM link tagging to run after `_.merge()` so caller overrides to `body.message` and `email.body` are properly processed
|
|
20
|
+
|
|
17
21
|
# [5.0.185] - 2026-04-01
|
|
18
22
|
### Changed
|
|
19
23
|
- Use `_.merge` for dynamic template data so callers can override any nested field (e.g. `email.preview`, `personalization.name`, `data.body.*`)
|
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/TODO-2.md
CHANGED
|
@@ -10,10 +10,26 @@ payments/reactivate
|
|
|
10
10
|
payments/upgrade
|
|
11
11
|
* takes a subscription id and a new plan id and upgrades the user's subscription to the new plan. this can only be done if the user has an active subscription.
|
|
12
12
|
|
|
13
|
+
I'd call that cancellation-reversed — it pairs naturally with the existing cancellation-requested transition and clearly communicates the action (they reversed/undid the pending cancellation).
|
|
14
|
+
|
|
15
|
+
on the email we send, for payment recovered what is the category
|
|
16
|
+
Let me check the handler.
|
|
17
|
+
|
|
18
|
+
Read payment-recovered.js
|
|
19
|
+
The category is order/payment-recovered (line 13).
|
|
20
|
+
|
|
21
|
+
TODO-2.md
|
|
22
|
+
waht about when they request a cancel
|
|
23
|
+
Read cancellation-requested.js
|
|
24
|
+
The category is order/cancellation-requested (line 13).
|
|
25
|
+
|
|
13
26
|
---
|
|
14
27
|
GHOSTII REVAMP
|
|
15
28
|
* better logic for generating posts. better model? claude?
|
|
16
29
|
|
|
30
|
+
---- MCP
|
|
31
|
+
* ability for consuming prjec to specify MCP functions
|
|
32
|
+
|
|
17
33
|
-------
|
|
18
34
|
UPSELL
|
|
19
35
|
* products in BEM can have an UPSELL where you link another product ID and it allows you to add it to your cart OR shows you after checkout?
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
https://github.com/disposable-email-domains/disposable-email-domains?tab=readme-ov-file
|
|
2
|
+
https://www.npmjs.com/package/disposable-domains
|
|
3
|
+
https://github.com/tompec/disposable-email-domains
|
|
4
|
+
|
|
5
|
+
Two repos:
|
|
6
|
+
|
|
7
|
+
Repo Domains Approach
|
|
8
|
+
disposable-email-domains/disposable-email-domains 5,359 Curated, conservative, high confidence
|
|
9
|
+
ivolo/disposable-email-domains 121,569 Aggressive, aggregated from many sources, more false positives
|
|
10
|
+
Our current list has 854 — so even the smaller curated list is 6x larger.
|
|
11
|
+
|
|
12
|
+
For our use case (currently only used to skip marketing sync, not blocking signups), I'd recommend the 5,359 curated list — it's comprehensive enough without being overly aggressive. And if we ever do use it for blocking signups, the false positive risk is much lower.
|
|
13
|
+
|
|
14
|
+
Want to swap to that one?
|
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;
|