@drawbridge/drawbridge-utils 0.0.96 → 0.0.98
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/dist/ai.cjs +11 -0
- package/dist/ai.js +11 -0
- package/dist/billing.cjs +11 -0
- package/dist/billing.d.cts +11 -0
- package/dist/billing.d.ts +11 -0
- package/dist/billing.js +11 -0
- package/dist/domains.cjs +109 -0
- package/dist/domains.d.cts +133 -0
- package/dist/domains.d.ts +133 -0
- package/dist/domains.js +81 -0
- package/dist/safe-http.cjs +180 -0
- package/dist/safe-http.d.cts +174 -0
- package/dist/safe-http.d.ts +174 -0
- package/dist/safe-http.js +143 -0
- package/package.json +12 -2
package/dist/ai.cjs
CHANGED
|
@@ -177,6 +177,17 @@ var cost = {
|
|
|
177
177
|
cached: 3,
|
|
178
178
|
input: 30,
|
|
179
179
|
output: 3e3
|
|
180
|
+
},
|
|
181
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
182
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
183
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
184
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
185
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
186
|
+
// reports. Growth's hero generation runs this model today.
|
|
187
|
+
"gemini-3-pro-image-preview": {
|
|
188
|
+
cached: 20,
|
|
189
|
+
input: 200,
|
|
190
|
+
output: 12e3
|
|
180
191
|
}
|
|
181
192
|
};
|
|
182
193
|
var toolCost = {
|
package/dist/ai.js
CHANGED
|
@@ -143,6 +143,17 @@ var cost = {
|
|
|
143
143
|
cached: 3,
|
|
144
144
|
input: 30,
|
|
145
145
|
output: 3e3
|
|
146
|
+
},
|
|
147
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
148
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
149
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
150
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
151
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
152
|
+
// reports. Growth's hero generation runs this model today.
|
|
153
|
+
"gemini-3-pro-image-preview": {
|
|
154
|
+
cached: 20,
|
|
155
|
+
input: 200,
|
|
156
|
+
output: 12e3
|
|
146
157
|
}
|
|
147
158
|
};
|
|
148
159
|
var toolCost = {
|
package/dist/billing.cjs
CHANGED
|
@@ -177,6 +177,17 @@ var cost = {
|
|
|
177
177
|
cached: 3,
|
|
178
178
|
input: 30,
|
|
179
179
|
output: 3e3
|
|
180
|
+
},
|
|
181
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
182
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
183
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
184
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
185
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
186
|
+
// reports. Growth's hero generation runs this model today.
|
|
187
|
+
"gemini-3-pro-image-preview": {
|
|
188
|
+
cached: 20,
|
|
189
|
+
input: 200,
|
|
190
|
+
output: 12e3
|
|
180
191
|
}
|
|
181
192
|
};
|
|
182
193
|
var toolCost = {
|
package/dist/billing.d.cts
CHANGED
|
@@ -48,6 +48,17 @@ const cost = {
|
|
|
48
48
|
cached : 3,
|
|
49
49
|
input : 30,
|
|
50
50
|
output : 3000
|
|
51
|
+
},
|
|
52
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
53
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
54
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
55
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
56
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
57
|
+
// reports. Growth's hero generation runs this model today.
|
|
58
|
+
'gemini-3-pro-image-preview' : {
|
|
59
|
+
cached : 20,
|
|
60
|
+
input : 200,
|
|
61
|
+
output : 12000
|
|
51
62
|
}
|
|
52
63
|
};
|
|
53
64
|
|
package/dist/billing.d.ts
CHANGED
|
@@ -48,6 +48,17 @@ const cost = {
|
|
|
48
48
|
cached : 3,
|
|
49
49
|
input : 30,
|
|
50
50
|
output : 3000
|
|
51
|
+
},
|
|
52
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
53
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
54
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
55
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
56
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
57
|
+
// reports. Growth's hero generation runs this model today.
|
|
58
|
+
'gemini-3-pro-image-preview' : {
|
|
59
|
+
cached : 20,
|
|
60
|
+
input : 200,
|
|
61
|
+
output : 12000
|
|
51
62
|
}
|
|
52
63
|
};
|
|
53
64
|
|
package/dist/billing.js
CHANGED
|
@@ -147,6 +147,17 @@ var cost = {
|
|
|
147
147
|
cached: 3,
|
|
148
148
|
input: 30,
|
|
149
149
|
output: 3e3
|
|
150
|
+
},
|
|
151
|
+
// gemini-3-pro-image-preview — verified against Google's pricing page
|
|
152
|
+
// 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
|
|
153
|
+
// tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
|
|
154
|
+
// 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
|
|
155
|
+
// that reproduces the per-image price from the tokens usageMetadata
|
|
156
|
+
// reports. Growth's hero generation runs this model today.
|
|
157
|
+
"gemini-3-pro-image-preview": {
|
|
158
|
+
cached: 20,
|
|
159
|
+
input: 200,
|
|
160
|
+
output: 12e3
|
|
150
161
|
}
|
|
151
162
|
};
|
|
152
163
|
var toolCost = {
|
package/dist/domains.cjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// lib/domains.js
|
|
20
|
+
var domains_exports = {};
|
|
21
|
+
__export(domains_exports, {
|
|
22
|
+
companyDomain: () => companyDomain,
|
|
23
|
+
freeEmailDomains: () => freeEmailDomains,
|
|
24
|
+
isFreeEmailDomain: () => isFreeEmailDomain,
|
|
25
|
+
normalizeDomain: () => normalizeDomain,
|
|
26
|
+
normalizeEmail: () => normalizeEmail
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(domains_exports);
|
|
29
|
+
var freeEmailDomains = /* @__PURE__ */ new Set([
|
|
30
|
+
"aol.com",
|
|
31
|
+
"comcast.net",
|
|
32
|
+
"gmail.com",
|
|
33
|
+
"googlemail.com",
|
|
34
|
+
"gmx.com",
|
|
35
|
+
"gmx.de",
|
|
36
|
+
"hey.com",
|
|
37
|
+
"hotmail.co.uk",
|
|
38
|
+
"hotmail.com",
|
|
39
|
+
"hotmail.fr",
|
|
40
|
+
"icloud.com",
|
|
41
|
+
"inbox.com",
|
|
42
|
+
"live.com",
|
|
43
|
+
"mac.com",
|
|
44
|
+
"mail.com",
|
|
45
|
+
"mail.ru",
|
|
46
|
+
"me.com",
|
|
47
|
+
"msn.com",
|
|
48
|
+
"outlook.com",
|
|
49
|
+
"pm.me",
|
|
50
|
+
"proton.me",
|
|
51
|
+
"protonmail.com",
|
|
52
|
+
"qq.com",
|
|
53
|
+
"rocketmail.com",
|
|
54
|
+
"sbcglobal.net",
|
|
55
|
+
"verizon.net",
|
|
56
|
+
"yahoo.co.uk",
|
|
57
|
+
"yahoo.com",
|
|
58
|
+
"yahoo.fr",
|
|
59
|
+
"yandex.com",
|
|
60
|
+
"yandex.ru",
|
|
61
|
+
"ymail.com",
|
|
62
|
+
"zoho.com"
|
|
63
|
+
]);
|
|
64
|
+
var isFreeEmailDomain = (domain) => {
|
|
65
|
+
if (typeof domain !== "string") return false;
|
|
66
|
+
return freeEmailDomains.has(domain.trim().toLowerCase());
|
|
67
|
+
};
|
|
68
|
+
var normalizeEmail = (value) => {
|
|
69
|
+
if (typeof value !== "string") return null;
|
|
70
|
+
const email = value.trim().toLowerCase().normalize("NFC");
|
|
71
|
+
if (!email.includes("@")) return null;
|
|
72
|
+
const [local, domain] = email.split("@");
|
|
73
|
+
if (!local || !domain || !domain.includes(".")) return null;
|
|
74
|
+
return email;
|
|
75
|
+
};
|
|
76
|
+
var normalizeDomain = (value) => {
|
|
77
|
+
if (typeof value !== "string") return null;
|
|
78
|
+
let host = value.trim().toLowerCase();
|
|
79
|
+
if (!host) return null;
|
|
80
|
+
if (host.includes("://")) {
|
|
81
|
+
try {
|
|
82
|
+
host = new URL(host).hostname;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
host = host.split("/")[0].split("?")[0];
|
|
88
|
+
}
|
|
89
|
+
host = host.replace(/^www\./, "");
|
|
90
|
+
if (!host.includes(".")) return null;
|
|
91
|
+
return host;
|
|
92
|
+
};
|
|
93
|
+
var companyDomain = ({ email, website } = {}) => {
|
|
94
|
+
const fromWebsite = normalizeDomain(website);
|
|
95
|
+
if (fromWebsite) return fromWebsite;
|
|
96
|
+
const normalized = normalizeEmail(email);
|
|
97
|
+
if (!normalized) return null;
|
|
98
|
+
const domain = normalized.split("@")[1];
|
|
99
|
+
if (isFreeEmailDomain(domain)) return null;
|
|
100
|
+
return domain;
|
|
101
|
+
};
|
|
102
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
103
|
+
0 && (module.exports = {
|
|
104
|
+
companyDomain,
|
|
105
|
+
freeEmailDomains,
|
|
106
|
+
isFreeEmailDomain,
|
|
107
|
+
normalizeDomain,
|
|
108
|
+
normalizeEmail
|
|
109
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Email-domain classification. Separate from ./email (which canonicalizes an
|
|
2
|
+
// address for identity matching) and from ./sanitize's disposable-domain check
|
|
3
|
+
// (which decides whether an address is acceptable at all) — this answers a
|
|
4
|
+
// third, orthogonal question: does this address belong to a company?
|
|
5
|
+
//
|
|
6
|
+
// A free-provider address is not disposable and a disposable address is not a
|
|
7
|
+
// free provider, so a caller that cares about both has to ask both.
|
|
8
|
+
|
|
9
|
+
// Consumer email providers. An email domain on this list must never stand in for
|
|
10
|
+
// a company — otherwise every gmail.com address would resolve to one giant
|
|
11
|
+
// "company" (and, for signup autogeneration, we would scrape gmail.com).
|
|
12
|
+
const freeEmailDomains = new Set([
|
|
13
|
+
'aol.com',
|
|
14
|
+
'comcast.net',
|
|
15
|
+
'gmail.com',
|
|
16
|
+
'googlemail.com',
|
|
17
|
+
'gmx.com',
|
|
18
|
+
'gmx.de',
|
|
19
|
+
'hey.com',
|
|
20
|
+
'hotmail.co.uk',
|
|
21
|
+
'hotmail.com',
|
|
22
|
+
'hotmail.fr',
|
|
23
|
+
'icloud.com',
|
|
24
|
+
'inbox.com',
|
|
25
|
+
'live.com',
|
|
26
|
+
'mac.com',
|
|
27
|
+
'mail.com',
|
|
28
|
+
'mail.ru',
|
|
29
|
+
'me.com',
|
|
30
|
+
'msn.com',
|
|
31
|
+
'outlook.com',
|
|
32
|
+
'pm.me',
|
|
33
|
+
'proton.me',
|
|
34
|
+
'protonmail.com',
|
|
35
|
+
'qq.com',
|
|
36
|
+
'rocketmail.com',
|
|
37
|
+
'sbcglobal.net',
|
|
38
|
+
'verizon.net',
|
|
39
|
+
'yahoo.co.uk',
|
|
40
|
+
'yahoo.com',
|
|
41
|
+
'yahoo.fr',
|
|
42
|
+
'yandex.com',
|
|
43
|
+
'yandex.ru',
|
|
44
|
+
'ymail.com',
|
|
45
|
+
'zoho.com'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Takes a BARE domain, not a full address.
|
|
49
|
+
const isFreeEmailDomain = ( domain ) => {
|
|
50
|
+
|
|
51
|
+
if( typeof domain !== 'string' ) return false;
|
|
52
|
+
|
|
53
|
+
return freeEmailDomains.has( domain.trim().toLowerCase() );
|
|
54
|
+
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Trim, lowercase, NFC-normalize. Unicode normalization matters because a
|
|
58
|
+
// composed and a decomposed é are different bytes but the same address.
|
|
59
|
+
const normalizeEmail = ( value ) => {
|
|
60
|
+
|
|
61
|
+
if( typeof value !== 'string' ) return null;
|
|
62
|
+
|
|
63
|
+
const email = value.trim().toLowerCase().normalize( 'NFC' );
|
|
64
|
+
|
|
65
|
+
if( ! email.includes( '@' ) ) return null;
|
|
66
|
+
|
|
67
|
+
// Reject obvious garbage rather than treating it as an address.
|
|
68
|
+
const [ local, domain ] = email.split( '@' );
|
|
69
|
+
|
|
70
|
+
if( ! local || ! domain || ! domain.includes( '.' ) ) return null;
|
|
71
|
+
|
|
72
|
+
return email;
|
|
73
|
+
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Host part of a URL or a bare hostname, lowercased with www. removed.
|
|
77
|
+
const normalizeDomain = ( value ) => {
|
|
78
|
+
|
|
79
|
+
if( typeof value !== 'string' ) return null;
|
|
80
|
+
|
|
81
|
+
let host = value.trim().toLowerCase();
|
|
82
|
+
|
|
83
|
+
if( ! host ) return null;
|
|
84
|
+
|
|
85
|
+
if( host.includes( '://' ) ){
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
|
|
89
|
+
host = new URL( host ).hostname;
|
|
90
|
+
|
|
91
|
+
} catch {
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
} else {
|
|
98
|
+
|
|
99
|
+
// Strip a path or query someone pasted in without a scheme.
|
|
100
|
+
host = host.split( '/' )[ 0 ].split( '?' )[ 0 ];
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
host = host.replace( /^www\./, '' );
|
|
105
|
+
|
|
106
|
+
if( ! host.includes( '.' ) ) return null;
|
|
107
|
+
|
|
108
|
+
return host;
|
|
109
|
+
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// The company domain behind an address, or null when there isn't one. An email
|
|
113
|
+
// domain only counts when it is not a consumer provider; an explicit website
|
|
114
|
+
// always counts and wins.
|
|
115
|
+
const companyDomain = ({ email, website } = {}) => {
|
|
116
|
+
|
|
117
|
+
const fromWebsite = normalizeDomain( website );
|
|
118
|
+
|
|
119
|
+
if( fromWebsite ) return fromWebsite;
|
|
120
|
+
|
|
121
|
+
const normalized = normalizeEmail( email );
|
|
122
|
+
|
|
123
|
+
if( ! normalized ) return null;
|
|
124
|
+
|
|
125
|
+
const domain = normalized.split( '@' )[ 1 ];
|
|
126
|
+
|
|
127
|
+
if( isFreeEmailDomain( domain ) ) return null;
|
|
128
|
+
|
|
129
|
+
return domain;
|
|
130
|
+
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export { companyDomain, freeEmailDomains, isFreeEmailDomain, normalizeDomain, normalizeEmail };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Email-domain classification. Separate from ./email (which canonicalizes an
|
|
2
|
+
// address for identity matching) and from ./sanitize's disposable-domain check
|
|
3
|
+
// (which decides whether an address is acceptable at all) — this answers a
|
|
4
|
+
// third, orthogonal question: does this address belong to a company?
|
|
5
|
+
//
|
|
6
|
+
// A free-provider address is not disposable and a disposable address is not a
|
|
7
|
+
// free provider, so a caller that cares about both has to ask both.
|
|
8
|
+
|
|
9
|
+
// Consumer email providers. An email domain on this list must never stand in for
|
|
10
|
+
// a company — otherwise every gmail.com address would resolve to one giant
|
|
11
|
+
// "company" (and, for signup autogeneration, we would scrape gmail.com).
|
|
12
|
+
const freeEmailDomains = new Set([
|
|
13
|
+
'aol.com',
|
|
14
|
+
'comcast.net',
|
|
15
|
+
'gmail.com',
|
|
16
|
+
'googlemail.com',
|
|
17
|
+
'gmx.com',
|
|
18
|
+
'gmx.de',
|
|
19
|
+
'hey.com',
|
|
20
|
+
'hotmail.co.uk',
|
|
21
|
+
'hotmail.com',
|
|
22
|
+
'hotmail.fr',
|
|
23
|
+
'icloud.com',
|
|
24
|
+
'inbox.com',
|
|
25
|
+
'live.com',
|
|
26
|
+
'mac.com',
|
|
27
|
+
'mail.com',
|
|
28
|
+
'mail.ru',
|
|
29
|
+
'me.com',
|
|
30
|
+
'msn.com',
|
|
31
|
+
'outlook.com',
|
|
32
|
+
'pm.me',
|
|
33
|
+
'proton.me',
|
|
34
|
+
'protonmail.com',
|
|
35
|
+
'qq.com',
|
|
36
|
+
'rocketmail.com',
|
|
37
|
+
'sbcglobal.net',
|
|
38
|
+
'verizon.net',
|
|
39
|
+
'yahoo.co.uk',
|
|
40
|
+
'yahoo.com',
|
|
41
|
+
'yahoo.fr',
|
|
42
|
+
'yandex.com',
|
|
43
|
+
'yandex.ru',
|
|
44
|
+
'ymail.com',
|
|
45
|
+
'zoho.com'
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Takes a BARE domain, not a full address.
|
|
49
|
+
const isFreeEmailDomain = ( domain ) => {
|
|
50
|
+
|
|
51
|
+
if( typeof domain !== 'string' ) return false;
|
|
52
|
+
|
|
53
|
+
return freeEmailDomains.has( domain.trim().toLowerCase() );
|
|
54
|
+
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Trim, lowercase, NFC-normalize. Unicode normalization matters because a
|
|
58
|
+
// composed and a decomposed é are different bytes but the same address.
|
|
59
|
+
const normalizeEmail = ( value ) => {
|
|
60
|
+
|
|
61
|
+
if( typeof value !== 'string' ) return null;
|
|
62
|
+
|
|
63
|
+
const email = value.trim().toLowerCase().normalize( 'NFC' );
|
|
64
|
+
|
|
65
|
+
if( ! email.includes( '@' ) ) return null;
|
|
66
|
+
|
|
67
|
+
// Reject obvious garbage rather than treating it as an address.
|
|
68
|
+
const [ local, domain ] = email.split( '@' );
|
|
69
|
+
|
|
70
|
+
if( ! local || ! domain || ! domain.includes( '.' ) ) return null;
|
|
71
|
+
|
|
72
|
+
return email;
|
|
73
|
+
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Host part of a URL or a bare hostname, lowercased with www. removed.
|
|
77
|
+
const normalizeDomain = ( value ) => {
|
|
78
|
+
|
|
79
|
+
if( typeof value !== 'string' ) return null;
|
|
80
|
+
|
|
81
|
+
let host = value.trim().toLowerCase();
|
|
82
|
+
|
|
83
|
+
if( ! host ) return null;
|
|
84
|
+
|
|
85
|
+
if( host.includes( '://' ) ){
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
|
|
89
|
+
host = new URL( host ).hostname;
|
|
90
|
+
|
|
91
|
+
} catch {
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
} else {
|
|
98
|
+
|
|
99
|
+
// Strip a path or query someone pasted in without a scheme.
|
|
100
|
+
host = host.split( '/' )[ 0 ].split( '?' )[ 0 ];
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
host = host.replace( /^www\./, '' );
|
|
105
|
+
|
|
106
|
+
if( ! host.includes( '.' ) ) return null;
|
|
107
|
+
|
|
108
|
+
return host;
|
|
109
|
+
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// The company domain behind an address, or null when there isn't one. An email
|
|
113
|
+
// domain only counts when it is not a consumer provider; an explicit website
|
|
114
|
+
// always counts and wins.
|
|
115
|
+
const companyDomain = ({ email, website } = {}) => {
|
|
116
|
+
|
|
117
|
+
const fromWebsite = normalizeDomain( website );
|
|
118
|
+
|
|
119
|
+
if( fromWebsite ) return fromWebsite;
|
|
120
|
+
|
|
121
|
+
const normalized = normalizeEmail( email );
|
|
122
|
+
|
|
123
|
+
if( ! normalized ) return null;
|
|
124
|
+
|
|
125
|
+
const domain = normalized.split( '@' )[ 1 ];
|
|
126
|
+
|
|
127
|
+
if( isFreeEmailDomain( domain ) ) return null;
|
|
128
|
+
|
|
129
|
+
return domain;
|
|
130
|
+
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export { companyDomain, freeEmailDomains, isFreeEmailDomain, normalizeDomain, normalizeEmail };
|
package/dist/domains.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// lib/domains.js
|
|
2
|
+
var freeEmailDomains = /* @__PURE__ */ new Set([
|
|
3
|
+
"aol.com",
|
|
4
|
+
"comcast.net",
|
|
5
|
+
"gmail.com",
|
|
6
|
+
"googlemail.com",
|
|
7
|
+
"gmx.com",
|
|
8
|
+
"gmx.de",
|
|
9
|
+
"hey.com",
|
|
10
|
+
"hotmail.co.uk",
|
|
11
|
+
"hotmail.com",
|
|
12
|
+
"hotmail.fr",
|
|
13
|
+
"icloud.com",
|
|
14
|
+
"inbox.com",
|
|
15
|
+
"live.com",
|
|
16
|
+
"mac.com",
|
|
17
|
+
"mail.com",
|
|
18
|
+
"mail.ru",
|
|
19
|
+
"me.com",
|
|
20
|
+
"msn.com",
|
|
21
|
+
"outlook.com",
|
|
22
|
+
"pm.me",
|
|
23
|
+
"proton.me",
|
|
24
|
+
"protonmail.com",
|
|
25
|
+
"qq.com",
|
|
26
|
+
"rocketmail.com",
|
|
27
|
+
"sbcglobal.net",
|
|
28
|
+
"verizon.net",
|
|
29
|
+
"yahoo.co.uk",
|
|
30
|
+
"yahoo.com",
|
|
31
|
+
"yahoo.fr",
|
|
32
|
+
"yandex.com",
|
|
33
|
+
"yandex.ru",
|
|
34
|
+
"ymail.com",
|
|
35
|
+
"zoho.com"
|
|
36
|
+
]);
|
|
37
|
+
var isFreeEmailDomain = (domain) => {
|
|
38
|
+
if (typeof domain !== "string") return false;
|
|
39
|
+
return freeEmailDomains.has(domain.trim().toLowerCase());
|
|
40
|
+
};
|
|
41
|
+
var normalizeEmail = (value) => {
|
|
42
|
+
if (typeof value !== "string") return null;
|
|
43
|
+
const email = value.trim().toLowerCase().normalize("NFC");
|
|
44
|
+
if (!email.includes("@")) return null;
|
|
45
|
+
const [local, domain] = email.split("@");
|
|
46
|
+
if (!local || !domain || !domain.includes(".")) return null;
|
|
47
|
+
return email;
|
|
48
|
+
};
|
|
49
|
+
var normalizeDomain = (value) => {
|
|
50
|
+
if (typeof value !== "string") return null;
|
|
51
|
+
let host = value.trim().toLowerCase();
|
|
52
|
+
if (!host) return null;
|
|
53
|
+
if (host.includes("://")) {
|
|
54
|
+
try {
|
|
55
|
+
host = new URL(host).hostname;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
host = host.split("/")[0].split("?")[0];
|
|
61
|
+
}
|
|
62
|
+
host = host.replace(/^www\./, "");
|
|
63
|
+
if (!host.includes(".")) return null;
|
|
64
|
+
return host;
|
|
65
|
+
};
|
|
66
|
+
var companyDomain = ({ email, website } = {}) => {
|
|
67
|
+
const fromWebsite = normalizeDomain(website);
|
|
68
|
+
if (fromWebsite) return fromWebsite;
|
|
69
|
+
const normalized = normalizeEmail(email);
|
|
70
|
+
if (!normalized) return null;
|
|
71
|
+
const domain = normalized.split("@")[1];
|
|
72
|
+
if (isFreeEmailDomain(domain)) return null;
|
|
73
|
+
return domain;
|
|
74
|
+
};
|
|
75
|
+
export {
|
|
76
|
+
companyDomain,
|
|
77
|
+
freeEmailDomains,
|
|
78
|
+
isFreeEmailDomain,
|
|
79
|
+
normalizeDomain,
|
|
80
|
+
normalizeEmail
|
|
81
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
|
|
29
|
+
// lib/safe-http.js
|
|
30
|
+
var safe_http_exports = {};
|
|
31
|
+
__export(safe_http_exports, {
|
|
32
|
+
assertSafeHost: () => assertSafeHost,
|
|
33
|
+
pinnedAgent: () => pinnedAgent,
|
|
34
|
+
resolveSafeHost: () => resolveSafeHost,
|
|
35
|
+
safeRequest: () => safeRequest
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(safe_http_exports);
|
|
38
|
+
var import_dns2 = __toESM(require("dns"), 1);
|
|
39
|
+
var http2 = __toESM(require("http"), 1);
|
|
40
|
+
var https2 = __toESM(require("https"), 1);
|
|
41
|
+
|
|
42
|
+
// lib/axios.js
|
|
43
|
+
var import_axios = __toESM(require("axios"), 1);
|
|
44
|
+
var import_dns = __toESM(require("dns"), 1);
|
|
45
|
+
var http = __toESM(require("http"), 1);
|
|
46
|
+
var https = __toESM(require("https"), 1);
|
|
47
|
+
var import_net = __toESM(require("net"), 1);
|
|
48
|
+
var dnsLookup = import_dns.default.promises.lookup;
|
|
49
|
+
var isBlockedIPv4 = (ip) => {
|
|
50
|
+
const parts = ip.split(".").map(Number);
|
|
51
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
|
52
|
+
const [a, b] = parts;
|
|
53
|
+
if (a === 0) return true;
|
|
54
|
+
if (a === 10) return true;
|
|
55
|
+
if (a === 127) return true;
|
|
56
|
+
if (a === 169 && b === 254) return true;
|
|
57
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
58
|
+
if (a === 192 && b === 168) return true;
|
|
59
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
60
|
+
if (a === 192 && b === 0) return true;
|
|
61
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
62
|
+
if (a === 198 && b === 51) return true;
|
|
63
|
+
if (a === 203 && b === 0) return true;
|
|
64
|
+
if (a >= 224) return true;
|
|
65
|
+
return false;
|
|
66
|
+
};
|
|
67
|
+
var isBlockedIPv6 = (ip) => {
|
|
68
|
+
const lower = ip.toLowerCase();
|
|
69
|
+
if (lower === "::1" || lower === "::") return true;
|
|
70
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
71
|
+
if (/^fe[89ab]/.test(lower)) return true;
|
|
72
|
+
if (lower.startsWith("ff")) return true;
|
|
73
|
+
if (lower.startsWith("::ffff:")) {
|
|
74
|
+
const v4 = lower.slice(7);
|
|
75
|
+
return isBlockedIPv4(v4);
|
|
76
|
+
}
|
|
77
|
+
;
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
var isBlockedIP = (ip) => {
|
|
81
|
+
const version = import_net.default.isIP(ip);
|
|
82
|
+
if (version === 4) return isBlockedIPv4(ip);
|
|
83
|
+
if (version === 6) return isBlockedIPv6(ip);
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
var axios = import_axios.default.create({
|
|
87
|
+
timeout: 3e4,
|
|
88
|
+
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
|
|
89
|
+
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// lib/safe-http.js
|
|
93
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
94
|
+
var resolveSafeHost = async (url) => {
|
|
95
|
+
const parsed = new URL(url);
|
|
96
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
97
|
+
throw new Error("Only http(s) URLs are allowed");
|
|
98
|
+
}
|
|
99
|
+
const records = await import_dns2.default.promises.lookup(parsed.hostname, { all: true });
|
|
100
|
+
if (!(records == null ? void 0 : records.length)) {
|
|
101
|
+
throw new Error("Host could not be resolved");
|
|
102
|
+
}
|
|
103
|
+
for (const record of records) {
|
|
104
|
+
if (isBlockedIP(record.address)) {
|
|
105
|
+
throw new Error("Host resolves to a blocked IP range");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { parsed, records };
|
|
109
|
+
};
|
|
110
|
+
var assertSafeHost = async (url) => {
|
|
111
|
+
await resolveSafeHost(url);
|
|
112
|
+
};
|
|
113
|
+
var pinnedAgent = async (url) => {
|
|
114
|
+
const { parsed, records } = await resolveSafeHost(url);
|
|
115
|
+
const pinned = records[0];
|
|
116
|
+
const lookup = (hostname, options, callback) => {
|
|
117
|
+
if (options == null ? void 0 : options.all) {
|
|
118
|
+
callback(null, [{ address: pinned.address, family: pinned.family }]);
|
|
119
|
+
} else {
|
|
120
|
+
callback(null, pinned.address, pinned.family);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
protocol: parsed.protocol,
|
|
125
|
+
agent: parsed.protocol === "https:" ? new https2.Agent({ lookup }) : new http2.Agent({ lookup })
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
var safeRequest = async ({
|
|
129
|
+
body,
|
|
130
|
+
headers = {},
|
|
131
|
+
method = "GET",
|
|
132
|
+
query,
|
|
133
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
134
|
+
type = "json",
|
|
135
|
+
url
|
|
136
|
+
}) => {
|
|
137
|
+
const full = new URL(url);
|
|
138
|
+
if (query) {
|
|
139
|
+
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
140
|
+
}
|
|
141
|
+
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
142
|
+
const isForm = type === "form";
|
|
143
|
+
try {
|
|
144
|
+
const response = await axios({
|
|
145
|
+
method,
|
|
146
|
+
url: full.toString(),
|
|
147
|
+
headers: {
|
|
148
|
+
"Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
|
|
149
|
+
...headers
|
|
150
|
+
},
|
|
151
|
+
...body !== void 0 && {
|
|
152
|
+
data: isForm ? new URLSearchParams(body).toString() : body
|
|
153
|
+
},
|
|
154
|
+
timeout,
|
|
155
|
+
maxRedirects: 0,
|
|
156
|
+
httpAgent: protocol === "http:" ? agent : void 0,
|
|
157
|
+
httpsAgent: protocol === "https:" ? agent : void 0,
|
|
158
|
+
responseType: "json",
|
|
159
|
+
validateStatus: (status) => status >= 200 && status < 300
|
|
160
|
+
});
|
|
161
|
+
return response.data || null;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error == null ? void 0 : error.response) {
|
|
164
|
+
const normalized = new Error(
|
|
165
|
+
typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
|
|
166
|
+
);
|
|
167
|
+
normalized.status = error.response.status;
|
|
168
|
+
normalized.response = error.response.data;
|
|
169
|
+
throw normalized;
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
175
|
+
0 && (module.exports = {
|
|
176
|
+
assertSafeHost,
|
|
177
|
+
pinnedAgent,
|
|
178
|
+
resolveSafeHost,
|
|
179
|
+
safeRequest
|
|
180
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import dns from 'dns';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as https from 'node:https';
|
|
4
|
+
import { isBlockedIP, axios } from './axios.cjs';
|
|
5
|
+
import 'axios';
|
|
6
|
+
import 'net';
|
|
7
|
+
|
|
8
|
+
// SSRF-safe outbound HTTP for user-supplied URLs (scrape targets, video assets,
|
|
9
|
+
// outgoing webhooks, signup domain pre-flights). The sibling `safe.get` in
|
|
10
|
+
// ./axios is https-only, GET-only, and hard-caps the body at 5MB — too small for
|
|
11
|
+
// video and no help for POST/redirect control — so this offers the same
|
|
12
|
+
// protection with the transport under the caller's control:
|
|
13
|
+
//
|
|
14
|
+
// 1. resolve the host and reject if ANY record is a private/reserved IP
|
|
15
|
+
// (blocks direct navigation to 10.x / 127.x / 169.254.169.254 / etc.)
|
|
16
|
+
// 2. pin the vetted IP on the connection agent so a second lookup can't be
|
|
17
|
+
// re-pointed at an internal address between the check and the connect
|
|
18
|
+
// (DNS rebinding), and
|
|
19
|
+
// 3. callers pass maxRedirects:0 — a redirect to an internal host would
|
|
20
|
+
// otherwise re-resolve past the pin.
|
|
21
|
+
//
|
|
22
|
+
// isBlockedIP is the shared allow/deny logic in ./axios, so this and safe.get
|
|
23
|
+
// stay in lockstep on what counts as a blocked range.
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
26
|
+
|
|
27
|
+
// Resolve + validate a URL's host. Throws if the URL is not http(s), the host is
|
|
28
|
+
// unresolvable, or any resolved record is a blocked IP. Returns the parsed URL
|
|
29
|
+
// and its DNS records so callers can pin.
|
|
30
|
+
const resolveSafeHost = async ( url ) => {
|
|
31
|
+
|
|
32
|
+
const parsed = new URL( url );
|
|
33
|
+
|
|
34
|
+
if( parsed.protocol !== 'https:' && parsed.protocol !== 'http:' ){
|
|
35
|
+
|
|
36
|
+
throw new Error( 'Only http(s) URLs are allowed' );
|
|
37
|
+
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const records = await dns.promises.lookup( parsed.hostname, { all : true } );
|
|
41
|
+
|
|
42
|
+
if( ! records?.length ){
|
|
43
|
+
|
|
44
|
+
throw new Error( 'Host could not be resolved' );
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for( const record of records ){
|
|
49
|
+
|
|
50
|
+
if( isBlockedIP( record.address ) ){
|
|
51
|
+
|
|
52
|
+
throw new Error( 'Host resolves to a blocked IP range' );
|
|
53
|
+
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { parsed, records };
|
|
59
|
+
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Throwing guard for callers that manage their own transport (e.g. Puppeteer,
|
|
63
|
+
// which does its own DNS/redirects and can't take our agent). Rejects the URL if
|
|
64
|
+
// it resolves to a blocked range.
|
|
65
|
+
const assertSafeHost = async ( url ) => {
|
|
66
|
+
|
|
67
|
+
await resolveSafeHost( url );
|
|
68
|
+
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Build an http(s).Agent pinned to the vetted IP for `url`'s host. Pair with
|
|
72
|
+
// maxRedirects:0 on the request. Throws (via resolveSafeHost) if the host is blocked.
|
|
73
|
+
const pinnedAgent = async ( url ) => {
|
|
74
|
+
|
|
75
|
+
const { parsed, records } = await resolveSafeHost( url );
|
|
76
|
+
const pinned = records[ 0 ];
|
|
77
|
+
|
|
78
|
+
const lookup = ( hostname, options, callback ) => {
|
|
79
|
+
|
|
80
|
+
if( options?.all ){
|
|
81
|
+
|
|
82
|
+
callback( null, [ { address : pinned.address, family : pinned.family } ] );
|
|
83
|
+
|
|
84
|
+
} else {
|
|
85
|
+
|
|
86
|
+
callback( null, pinned.address, pinned.family );
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
protocol : parsed.protocol,
|
|
94
|
+
agent : parsed.protocol === 'https:'
|
|
95
|
+
? new https.Agent({ lookup })
|
|
96
|
+
: new http.Agent({ lookup })
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// SSRF-safe drop-in for ./http `request`: identical call / return / throw
|
|
102
|
+
// contract (parsed JSON body on success, throws Error with .status on non-2xx),
|
|
103
|
+
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
|
+
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
|
+
// redirect-to-internal vector.
|
|
106
|
+
const safeRequest = async ({
|
|
107
|
+
body,
|
|
108
|
+
headers = {},
|
|
109
|
+
method = 'GET',
|
|
110
|
+
query,
|
|
111
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
|
+
type = 'json',
|
|
113
|
+
url
|
|
114
|
+
}) => {
|
|
115
|
+
|
|
116
|
+
const full = new URL( url );
|
|
117
|
+
|
|
118
|
+
if( query ){
|
|
119
|
+
|
|
120
|
+
Object.entries( query ).forEach( ([ key, value ]) => full.searchParams.set( key, value ) );
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { protocol, agent } = await pinnedAgent( full.toString() );
|
|
125
|
+
const isForm = type === 'form';
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
|
|
129
|
+
const response = await axios({
|
|
130
|
+
method,
|
|
131
|
+
url : full.toString(),
|
|
132
|
+
headers : {
|
|
133
|
+
'Content-Type' : isForm ? 'application/x-www-form-urlencoded' : 'application/json',
|
|
134
|
+
...headers
|
|
135
|
+
},
|
|
136
|
+
...( body !== undefined && {
|
|
137
|
+
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
|
+
}),
|
|
139
|
+
timeout,
|
|
140
|
+
maxRedirects : 0,
|
|
141
|
+
httpAgent : protocol === 'http:' ? agent : undefined,
|
|
142
|
+
httpsAgent : protocol === 'https:' ? agent : undefined,
|
|
143
|
+
responseType : 'json',
|
|
144
|
+
validateStatus : ( status ) => status >= 200 && status < 300
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return response.data || null;
|
|
148
|
+
|
|
149
|
+
} catch ( error ) {
|
|
150
|
+
|
|
151
|
+
// Normalize to the ./http `request` error shape so existing callers
|
|
152
|
+
// (which read error.status / error.message / error.response) behave the same.
|
|
153
|
+
if( error?.response ){
|
|
154
|
+
|
|
155
|
+
const normalized = new Error(
|
|
156
|
+
typeof error.response.data === 'string'
|
|
157
|
+
? error.response.data
|
|
158
|
+
: ( error.message || 'Request failed' )
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
normalized.status = error.response.status;
|
|
162
|
+
normalized.response = error.response.data;
|
|
163
|
+
|
|
164
|
+
throw normalized;
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw error;
|
|
169
|
+
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export { assertSafeHost, pinnedAgent, resolveSafeHost, safeRequest };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import dns from 'dns';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as https from 'node:https';
|
|
4
|
+
import { isBlockedIP, axios } from './axios.js';
|
|
5
|
+
import 'axios';
|
|
6
|
+
import 'net';
|
|
7
|
+
|
|
8
|
+
// SSRF-safe outbound HTTP for user-supplied URLs (scrape targets, video assets,
|
|
9
|
+
// outgoing webhooks, signup domain pre-flights). The sibling `safe.get` in
|
|
10
|
+
// ./axios is https-only, GET-only, and hard-caps the body at 5MB — too small for
|
|
11
|
+
// video and no help for POST/redirect control — so this offers the same
|
|
12
|
+
// protection with the transport under the caller's control:
|
|
13
|
+
//
|
|
14
|
+
// 1. resolve the host and reject if ANY record is a private/reserved IP
|
|
15
|
+
// (blocks direct navigation to 10.x / 127.x / 169.254.169.254 / etc.)
|
|
16
|
+
// 2. pin the vetted IP on the connection agent so a second lookup can't be
|
|
17
|
+
// re-pointed at an internal address between the check and the connect
|
|
18
|
+
// (DNS rebinding), and
|
|
19
|
+
// 3. callers pass maxRedirects:0 — a redirect to an internal host would
|
|
20
|
+
// otherwise re-resolve past the pin.
|
|
21
|
+
//
|
|
22
|
+
// isBlockedIP is the shared allow/deny logic in ./axios, so this and safe.get
|
|
23
|
+
// stay in lockstep on what counts as a blocked range.
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
26
|
+
|
|
27
|
+
// Resolve + validate a URL's host. Throws if the URL is not http(s), the host is
|
|
28
|
+
// unresolvable, or any resolved record is a blocked IP. Returns the parsed URL
|
|
29
|
+
// and its DNS records so callers can pin.
|
|
30
|
+
const resolveSafeHost = async ( url ) => {
|
|
31
|
+
|
|
32
|
+
const parsed = new URL( url );
|
|
33
|
+
|
|
34
|
+
if( parsed.protocol !== 'https:' && parsed.protocol !== 'http:' ){
|
|
35
|
+
|
|
36
|
+
throw new Error( 'Only http(s) URLs are allowed' );
|
|
37
|
+
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const records = await dns.promises.lookup( parsed.hostname, { all : true } );
|
|
41
|
+
|
|
42
|
+
if( ! records?.length ){
|
|
43
|
+
|
|
44
|
+
throw new Error( 'Host could not be resolved' );
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for( const record of records ){
|
|
49
|
+
|
|
50
|
+
if( isBlockedIP( record.address ) ){
|
|
51
|
+
|
|
52
|
+
throw new Error( 'Host resolves to a blocked IP range' );
|
|
53
|
+
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { parsed, records };
|
|
59
|
+
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Throwing guard for callers that manage their own transport (e.g. Puppeteer,
|
|
63
|
+
// which does its own DNS/redirects and can't take our agent). Rejects the URL if
|
|
64
|
+
// it resolves to a blocked range.
|
|
65
|
+
const assertSafeHost = async ( url ) => {
|
|
66
|
+
|
|
67
|
+
await resolveSafeHost( url );
|
|
68
|
+
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Build an http(s).Agent pinned to the vetted IP for `url`'s host. Pair with
|
|
72
|
+
// maxRedirects:0 on the request. Throws (via resolveSafeHost) if the host is blocked.
|
|
73
|
+
const pinnedAgent = async ( url ) => {
|
|
74
|
+
|
|
75
|
+
const { parsed, records } = await resolveSafeHost( url );
|
|
76
|
+
const pinned = records[ 0 ];
|
|
77
|
+
|
|
78
|
+
const lookup = ( hostname, options, callback ) => {
|
|
79
|
+
|
|
80
|
+
if( options?.all ){
|
|
81
|
+
|
|
82
|
+
callback( null, [ { address : pinned.address, family : pinned.family } ] );
|
|
83
|
+
|
|
84
|
+
} else {
|
|
85
|
+
|
|
86
|
+
callback( null, pinned.address, pinned.family );
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
protocol : parsed.protocol,
|
|
94
|
+
agent : parsed.protocol === 'https:'
|
|
95
|
+
? new https.Agent({ lookup })
|
|
96
|
+
: new http.Agent({ lookup })
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// SSRF-safe drop-in for ./http `request`: identical call / return / throw
|
|
102
|
+
// contract (parsed JSON body on success, throws Error with .status on non-2xx),
|
|
103
|
+
// but pins the vetted IP and refuses redirects. A webhook endpoint that
|
|
104
|
+
// 3xx-redirects is treated as a failure — an intentional trade for closing the
|
|
105
|
+
// redirect-to-internal vector.
|
|
106
|
+
const safeRequest = async ({
|
|
107
|
+
body,
|
|
108
|
+
headers = {},
|
|
109
|
+
method = 'GET',
|
|
110
|
+
query,
|
|
111
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
112
|
+
type = 'json',
|
|
113
|
+
url
|
|
114
|
+
}) => {
|
|
115
|
+
|
|
116
|
+
const full = new URL( url );
|
|
117
|
+
|
|
118
|
+
if( query ){
|
|
119
|
+
|
|
120
|
+
Object.entries( query ).forEach( ([ key, value ]) => full.searchParams.set( key, value ) );
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { protocol, agent } = await pinnedAgent( full.toString() );
|
|
125
|
+
const isForm = type === 'form';
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
|
|
129
|
+
const response = await axios({
|
|
130
|
+
method,
|
|
131
|
+
url : full.toString(),
|
|
132
|
+
headers : {
|
|
133
|
+
'Content-Type' : isForm ? 'application/x-www-form-urlencoded' : 'application/json',
|
|
134
|
+
...headers
|
|
135
|
+
},
|
|
136
|
+
...( body !== undefined && {
|
|
137
|
+
data : isForm ? new URLSearchParams( body ).toString() : body
|
|
138
|
+
}),
|
|
139
|
+
timeout,
|
|
140
|
+
maxRedirects : 0,
|
|
141
|
+
httpAgent : protocol === 'http:' ? agent : undefined,
|
|
142
|
+
httpsAgent : protocol === 'https:' ? agent : undefined,
|
|
143
|
+
responseType : 'json',
|
|
144
|
+
validateStatus : ( status ) => status >= 200 && status < 300
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return response.data || null;
|
|
148
|
+
|
|
149
|
+
} catch ( error ) {
|
|
150
|
+
|
|
151
|
+
// Normalize to the ./http `request` error shape so existing callers
|
|
152
|
+
// (which read error.status / error.message / error.response) behave the same.
|
|
153
|
+
if( error?.response ){
|
|
154
|
+
|
|
155
|
+
const normalized = new Error(
|
|
156
|
+
typeof error.response.data === 'string'
|
|
157
|
+
? error.response.data
|
|
158
|
+
: ( error.message || 'Request failed' )
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
normalized.status = error.response.status;
|
|
162
|
+
normalized.response = error.response.data;
|
|
163
|
+
|
|
164
|
+
throw normalized;
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw error;
|
|
169
|
+
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export { assertSafeHost, pinnedAgent, resolveSafeHost, safeRequest };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// lib/safe-http.js
|
|
2
|
+
import dns2 from "dns";
|
|
3
|
+
import * as http2 from "http";
|
|
4
|
+
import * as https2 from "https";
|
|
5
|
+
|
|
6
|
+
// lib/axios.js
|
|
7
|
+
import axiosLib from "axios";
|
|
8
|
+
import dns from "dns";
|
|
9
|
+
import * as http from "http";
|
|
10
|
+
import * as https from "https";
|
|
11
|
+
import net from "net";
|
|
12
|
+
var dnsLookup = dns.promises.lookup;
|
|
13
|
+
var isBlockedIPv4 = (ip) => {
|
|
14
|
+
const parts = ip.split(".").map(Number);
|
|
15
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
|
16
|
+
const [a, b] = parts;
|
|
17
|
+
if (a === 0) return true;
|
|
18
|
+
if (a === 10) return true;
|
|
19
|
+
if (a === 127) return true;
|
|
20
|
+
if (a === 169 && b === 254) return true;
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
22
|
+
if (a === 192 && b === 168) return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
24
|
+
if (a === 192 && b === 0) return true;
|
|
25
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
26
|
+
if (a === 198 && b === 51) return true;
|
|
27
|
+
if (a === 203 && b === 0) return true;
|
|
28
|
+
if (a >= 224) return true;
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
var isBlockedIPv6 = (ip) => {
|
|
32
|
+
const lower = ip.toLowerCase();
|
|
33
|
+
if (lower === "::1" || lower === "::") return true;
|
|
34
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
35
|
+
if (/^fe[89ab]/.test(lower)) return true;
|
|
36
|
+
if (lower.startsWith("ff")) return true;
|
|
37
|
+
if (lower.startsWith("::ffff:")) {
|
|
38
|
+
const v4 = lower.slice(7);
|
|
39
|
+
return isBlockedIPv4(v4);
|
|
40
|
+
}
|
|
41
|
+
;
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
var isBlockedIP = (ip) => {
|
|
45
|
+
const version = net.isIP(ip);
|
|
46
|
+
if (version === 4) return isBlockedIPv4(ip);
|
|
47
|
+
if (version === 6) return isBlockedIPv6(ip);
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
var axios = axiosLib.create({
|
|
51
|
+
timeout: 3e4,
|
|
52
|
+
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 128 }),
|
|
53
|
+
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 128 })
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// lib/safe-http.js
|
|
57
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
58
|
+
var resolveSafeHost = async (url) => {
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
61
|
+
throw new Error("Only http(s) URLs are allowed");
|
|
62
|
+
}
|
|
63
|
+
const records = await dns2.promises.lookup(parsed.hostname, { all: true });
|
|
64
|
+
if (!(records == null ? void 0 : records.length)) {
|
|
65
|
+
throw new Error("Host could not be resolved");
|
|
66
|
+
}
|
|
67
|
+
for (const record of records) {
|
|
68
|
+
if (isBlockedIP(record.address)) {
|
|
69
|
+
throw new Error("Host resolves to a blocked IP range");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { parsed, records };
|
|
73
|
+
};
|
|
74
|
+
var assertSafeHost = async (url) => {
|
|
75
|
+
await resolveSafeHost(url);
|
|
76
|
+
};
|
|
77
|
+
var pinnedAgent = async (url) => {
|
|
78
|
+
const { parsed, records } = await resolveSafeHost(url);
|
|
79
|
+
const pinned = records[0];
|
|
80
|
+
const lookup = (hostname, options, callback) => {
|
|
81
|
+
if (options == null ? void 0 : options.all) {
|
|
82
|
+
callback(null, [{ address: pinned.address, family: pinned.family }]);
|
|
83
|
+
} else {
|
|
84
|
+
callback(null, pinned.address, pinned.family);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
protocol: parsed.protocol,
|
|
89
|
+
agent: parsed.protocol === "https:" ? new https2.Agent({ lookup }) : new http2.Agent({ lookup })
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
var safeRequest = async ({
|
|
93
|
+
body,
|
|
94
|
+
headers = {},
|
|
95
|
+
method = "GET",
|
|
96
|
+
query,
|
|
97
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
98
|
+
type = "json",
|
|
99
|
+
url
|
|
100
|
+
}) => {
|
|
101
|
+
const full = new URL(url);
|
|
102
|
+
if (query) {
|
|
103
|
+
Object.entries(query).forEach(([key, value]) => full.searchParams.set(key, value));
|
|
104
|
+
}
|
|
105
|
+
const { protocol, agent } = await pinnedAgent(full.toString());
|
|
106
|
+
const isForm = type === "form";
|
|
107
|
+
try {
|
|
108
|
+
const response = await axios({
|
|
109
|
+
method,
|
|
110
|
+
url: full.toString(),
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
|
|
113
|
+
...headers
|
|
114
|
+
},
|
|
115
|
+
...body !== void 0 && {
|
|
116
|
+
data: isForm ? new URLSearchParams(body).toString() : body
|
|
117
|
+
},
|
|
118
|
+
timeout,
|
|
119
|
+
maxRedirects: 0,
|
|
120
|
+
httpAgent: protocol === "http:" ? agent : void 0,
|
|
121
|
+
httpsAgent: protocol === "https:" ? agent : void 0,
|
|
122
|
+
responseType: "json",
|
|
123
|
+
validateStatus: (status) => status >= 200 && status < 300
|
|
124
|
+
});
|
|
125
|
+
return response.data || null;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error == null ? void 0 : error.response) {
|
|
128
|
+
const normalized = new Error(
|
|
129
|
+
typeof error.response.data === "string" ? error.response.data : error.message || "Request failed"
|
|
130
|
+
);
|
|
131
|
+
normalized.status = error.response.status;
|
|
132
|
+
normalized.response = error.response.data;
|
|
133
|
+
throw normalized;
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
export {
|
|
139
|
+
assertSafeHost,
|
|
140
|
+
pinnedAgent,
|
|
141
|
+
resolveSafeHost,
|
|
142
|
+
safeRequest
|
|
143
|
+
};
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"tinycolor2": "1.6.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@drawbridge/drawbridge-agents": "0.1.
|
|
16
|
+
"@drawbridge/drawbridge-agents": "0.1.31",
|
|
17
17
|
"@node-oauth/oauth2-server": "5.3.0",
|
|
18
18
|
"tsup": "8.5.1",
|
|
19
19
|
"typescript": "5.9.3"
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
"import": "./dist/color.js",
|
|
48
48
|
"require": "./dist/color.cjs"
|
|
49
49
|
},
|
|
50
|
+
"./domains": {
|
|
51
|
+
"types": "./dist/domains.d.ts",
|
|
52
|
+
"import": "./dist/domains.js",
|
|
53
|
+
"require": "./dist/domains.cjs"
|
|
54
|
+
},
|
|
50
55
|
"./email": {
|
|
51
56
|
"types": "./dist/email.d.ts",
|
|
52
57
|
"import": "./dist/email.js",
|
|
@@ -107,6 +112,11 @@
|
|
|
107
112
|
"import": "./dist/redirect.js",
|
|
108
113
|
"require": "./dist/redirect.cjs"
|
|
109
114
|
},
|
|
115
|
+
"./safe-http": {
|
|
116
|
+
"types": "./dist/safe-http.d.ts",
|
|
117
|
+
"import": "./dist/safe-http.js",
|
|
118
|
+
"require": "./dist/safe-http.cjs"
|
|
119
|
+
},
|
|
110
120
|
"./sanitize": {
|
|
111
121
|
"types": "./dist/sanitize.d.ts",
|
|
112
122
|
"import": "./dist/sanitize.js",
|
|
@@ -169,5 +179,5 @@
|
|
|
169
179
|
"test": ". \"$HOME/.nvm/nvm.sh\" && nvm use && node --test"
|
|
170
180
|
},
|
|
171
181
|
"types": "dist/index.d.ts",
|
|
172
|
-
"version": "0.0.
|
|
182
|
+
"version": "0.0.98"
|
|
173
183
|
}
|