@aglyn/tenant-data-admin 1.0.0-beta.200 → 1.0.0-beta.202
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/package.json +8 -7
- package/src/lib/server/card-payment-velocity.d.ts +85 -0
- package/src/lib/server/card-payment-velocity.js +202 -0
- package/src/lib/server/card-payment-velocity.js.map +1 -0
- package/src/lib/server/configured-url-fetch.d.ts +42 -0
- package/src/lib/server/configured-url-fetch.js +118 -0
- package/src/lib/server/configured-url-fetch.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +7 -0
- package/src/lib/server/dns-probe.js +52 -0
- package/src/lib/server/dns-probe.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +150 -0
- package/src/lib/server/email-deliverability.js +538 -0
- package/src/lib/server/email-deliverability.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +2 -11
- package/src/lib/server/email-sender-reputation.js +8 -25
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +10 -1
- package/src/lib/server/email-suppression.js +4 -3
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +31 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +16 -2
- package/src/lib/server/host-visitor-records.js +31 -6
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +112 -0
- package/src/lib/server/hosted-page-review.js +378 -0
- package/src/lib/server/hosted-page-review.js.map +1 -0
- package/src/lib/server/member-email-aliases.js +18 -7
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/notifications.d.ts +8 -0
- package/src/lib/server/notifications.js +92 -17
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-age.d.ts +27 -0
- package/src/lib/server/org-age.js +44 -0
- package/src/lib/server/org-age.js.map +1 -0
- package/src/lib/server/org-member-notice.js +20 -5
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.d.ts +10 -1
- package/src/lib/server/organizations.js +12 -4
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +224 -0
- package/src/lib/server/outbound-send-review.js +479 -0
- package/src/lib/server/outbound-send-review.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +156 -0
- package/src/lib/server/payment-fraud-signal.js +368 -0
- package/src/lib/server/payment-fraud-signal.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +59 -0
- package/src/lib/server/payment-risk-record.js +76 -0
- package/src/lib/server/payment-risk-record.js.map +1 -0
- package/src/lib/server/render-system-email.d.ts +176 -0
- package/src/lib/server/render-system-email.js +315 -0
- package/src/lib/server/render-system-email.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +6 -0
- package/src/lib/server/sending-domains.js +98 -6
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/staff-alert-email.d.ts +2 -1
- package/src/lib/server/staff-alert-email.js +16 -5
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/stripe-card-authentication.d.ts +35 -0
- package/src/lib/server/stripe-card-authentication.js +63 -0
- package/src/lib/server/stripe-card-authentication.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
- package/src/lib/server/tenant-write-lockdown.js +33 -3
- package/src/lib/server/tenant-write-lockdown.js.map +1 -1
- package/src/tenancy.d.ts +5 -0
- package/src/tenancy.js +10 -0
- package/src/tenancy.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.202",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.202",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.202",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.202",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.202",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.202",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.202",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"sideEffects": [
|
|
45
45
|
"./src/lib/server/client-error-report.*",
|
|
46
|
+
"./src/lib/server/email-deliverability.*",
|
|
46
47
|
"./src/lib/server/email-marketing-gate.*",
|
|
47
48
|
"./src/lib/server/email-send-rate.*"
|
|
48
49
|
],
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { notifyHostManagers, notifyStaff } from './notifications';
|
|
18
|
+
export interface CardPaymentVelocityOptions {
|
|
19
|
+
/** Dispatcher path, for the staff row. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** The site the payment is for; `''` when none resolved (still counted). */
|
|
22
|
+
hostId: string;
|
|
23
|
+
/** The workspace owning it, when the dispatcher read one. */
|
|
24
|
+
orgId?: string | null;
|
|
25
|
+
/** True for a workspace younger than the screen's young-workspace age. */
|
|
26
|
+
young?: boolean;
|
|
27
|
+
/** Read for its client-address headers. */
|
|
28
|
+
request: {
|
|
29
|
+
headers?: {
|
|
30
|
+
get?: (name: string) => unknown;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
nowMs?: number;
|
|
34
|
+
/** Injectable for tests; defaults to the Admin SDK's Firestore. */
|
|
35
|
+
firestore?: unknown;
|
|
36
|
+
/** Injectable for tests; defaults to `notifyStaff`. */
|
|
37
|
+
notify?: typeof notifyStaff;
|
|
38
|
+
/** Injectable for tests; defaults to `notifyHostManagers`. */
|
|
39
|
+
notifyManagers?: typeof notifyHostManagers;
|
|
40
|
+
}
|
|
41
|
+
/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */
|
|
42
|
+
export declare function cardPaymentAlarmReviewId(hostId: string, day: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Tell staff and the site's managers, once per site per day, that its payment
|
|
45
|
+
* doors crossed the card-testing alarm (AGL-3363). The one notifier for this
|
|
46
|
+
* kind of signal, so a shared risk-notice seam can take it over whole.
|
|
47
|
+
*
|
|
48
|
+
* The merchant's sentence names the outcome and what to do, never the rule
|
|
49
|
+
* or its numbers: a merchant who is the card tester must not learn how to
|
|
50
|
+
* pace under it. Never throws.
|
|
51
|
+
*/
|
|
52
|
+
export declare function notifyCardTestingVelocity(input: {
|
|
53
|
+
hostId: string;
|
|
54
|
+
orgId: string | null;
|
|
55
|
+
reference: string;
|
|
56
|
+
}, deps: {
|
|
57
|
+
notifyStaff: typeof notifyStaff;
|
|
58
|
+
notifyManagers: typeof notifyHostManagers;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* File (or count) the day's alarm row for a site whose payment doors crossed
|
|
62
|
+
* the site counter, and tell staff the first time. Never throws: an alarm
|
|
63
|
+
* that fails must not turn a shopper's checkout into a 500.
|
|
64
|
+
*/
|
|
65
|
+
export declare function recordCardPaymentAlarm(input: {
|
|
66
|
+
hostId: string;
|
|
67
|
+
orgId: string | null;
|
|
68
|
+
path: string;
|
|
69
|
+
young: boolean;
|
|
70
|
+
nowMs: number;
|
|
71
|
+
firestore: any;
|
|
72
|
+
notify: typeof notifyStaff;
|
|
73
|
+
notifyManagers: typeof notifyHostManagers;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
reviewId: string;
|
|
76
|
+
first: boolean;
|
|
77
|
+
} | null>;
|
|
78
|
+
/** Forget which alarms this process filed. */
|
|
79
|
+
export declare function resetCardPaymentAlarmsForTests(): void;
|
|
80
|
+
/**
|
|
81
|
+
* Count one opened card payment and return a 429 once a visitor's or an
|
|
82
|
+
* address's window is spent. See the policy module for the three counters.
|
|
83
|
+
*/
|
|
84
|
+
export declare function cardPaymentVelocityRefusal(options: CardPaymentVelocityOptions): Promise<Response | null>;
|
|
85
|
+
export default cardPaymentVelocityRefusal;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).
|
|
19
|
+
*
|
|
20
|
+
* The policy — the three counters, their numbers and why — is
|
|
21
|
+
* `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the
|
|
22
|
+
* shared durable store (`consumeRateLimit`) and answers the dispatcher the
|
|
23
|
+
* way the visitor-write limiter beside it does: a `Response` to return, or
|
|
24
|
+
* null to carry on. The tenant dispatcher calls it for a route registered
|
|
25
|
+
* with `{ cardPayment: true }`, so every plugin's payment door meets it by
|
|
26
|
+
* declaring itself, and none carries its own copy.
|
|
27
|
+
*
|
|
28
|
+
* The site counter refuses nothing. Crossing it files ONE urgent row per
|
|
29
|
+
* site per day in the abuse queue (`source: 'payment-velocity'`), beside the
|
|
30
|
+
* seller-pattern row, and tells staff once.
|
|
31
|
+
*=========================================*/ import { createHash } from "crypto";
|
|
32
|
+
import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
|
|
33
|
+
import { CARD_PAYMENT_VELOCITY, cardPaymentAddressKey, cardPaymentAlarmDay, cardPaymentSiteKey, cardPaymentVisitorKey } from "@aglyn/aglyn/app-utils/card-payment-velocity";
|
|
34
|
+
import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
|
|
35
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
36
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
37
|
+
import { notifyHostManagers, notifyStaff } from "./notifications.js";
|
|
38
|
+
import { consumeRateLimit } from "./rate-limit-store.js";
|
|
39
|
+
function clientIp(request) {
|
|
40
|
+
var _readClientIp;
|
|
41
|
+
const headers = request == null ? void 0 : request.headers;
|
|
42
|
+
if (typeof (headers == null ? void 0 : headers.get) !== 'function') return NO_CLIENT_ADDRESS_BUCKET;
|
|
43
|
+
return (_readClientIp = readClientIp(headers)) != null ? _readClientIp : NO_CLIENT_ADDRESS_BUCKET;
|
|
44
|
+
}
|
|
45
|
+
function refusal(resetMs, nowMs) {
|
|
46
|
+
// The same bare 429 as the visitor-write limiter: no remaining-count
|
|
47
|
+
// headers for a stranger to pace a script by.
|
|
48
|
+
return Response.json({
|
|
49
|
+
error: 'Too many payment attempts. Please wait a few minutes and try again.'
|
|
50
|
+
}, {
|
|
51
|
+
status: 429,
|
|
52
|
+
headers: {
|
|
53
|
+
'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000)))
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */ export function cardPaymentAlarmReviewId(hostId, day) {
|
|
58
|
+
return createHash('sha256').update(`payment-velocity:${hostId}:${day}`).digest('hex').slice(0, 40);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Tell staff and the site's managers, once per site per day, that its payment
|
|
62
|
+
* doors crossed the card-testing alarm (AGL-3363). The one notifier for this
|
|
63
|
+
* kind of signal, so a shared risk-notice seam can take it over whole.
|
|
64
|
+
*
|
|
65
|
+
* The merchant's sentence names the outcome and what to do, never the rule
|
|
66
|
+
* or its numbers: a merchant who is the card tester must not learn how to
|
|
67
|
+
* pace under it. Never throws.
|
|
68
|
+
*/ export async function notifyCardTestingVelocity(input, deps) {
|
|
69
|
+
await Promise.all([
|
|
70
|
+
deps.notifyStaff({
|
|
71
|
+
type: 'system.abuseReportUrgent',
|
|
72
|
+
title: `Card-testing velocity — site ${input.hostId}`,
|
|
73
|
+
body: 'An unusual number of card payments opened on one site' + (input.orgId ? ` (workspace ${input.orgId})` : '') + '. Nothing has been refused site-wide or refunded. Check the site and its ' + 'recent orders; if it is card testing, lock the workspace and pause the ' + `connected account's payouts. Reference ${input.reference}.`,
|
|
74
|
+
link: '/admin/abuse-reports'
|
|
75
|
+
}).catch(()=>undefined),
|
|
76
|
+
deps.notifyManagers(input.hostId, {
|
|
77
|
+
type: 'content.order',
|
|
78
|
+
title: 'Unusual checkout activity on your site',
|
|
79
|
+
body: 'Your checkout saw an unusual burst of payment attempts, which can be ' + 'someone testing stolen cards. Checkout is still open; repeated attempts ' + 'from one source are being slowed. Review recent orders before you ' + 'fulfill them, and refund any you do not recognize. If this was a launch ' + `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,
|
|
80
|
+
link: `/${input.hostId}/orders`
|
|
81
|
+
}).catch(()=>undefined)
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* File (or count) the day's alarm row for a site whose payment doors crossed
|
|
86
|
+
* the site counter, and tell staff the first time. Never throws: an alarm
|
|
87
|
+
* that fails must not turn a shopper's checkout into a 500.
|
|
88
|
+
*/ export async function recordCardPaymentAlarm(input) {
|
|
89
|
+
try {
|
|
90
|
+
const day = cardPaymentAlarmDay(input.nowMs);
|
|
91
|
+
const reviewId = cardPaymentAlarmReviewId(input.hostId, day);
|
|
92
|
+
const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`;
|
|
93
|
+
const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
|
|
94
|
+
const first = !(await ref.get()).exists;
|
|
95
|
+
const window = CARD_PAYMENT_VELOCITY.perSite;
|
|
96
|
+
const minutes = Math.round(window.windowMs / 60000);
|
|
97
|
+
await ref.set(_extends({
|
|
98
|
+
reference,
|
|
99
|
+
// "Phishing or fraud" — the queue's urgent fraud category.
|
|
100
|
+
category: 'phishing',
|
|
101
|
+
severity: 'urgent',
|
|
102
|
+
source: 'payment-velocity',
|
|
103
|
+
url: null,
|
|
104
|
+
reportedHostname: null,
|
|
105
|
+
hostId: input.hostId,
|
|
106
|
+
orgId: input.orgId,
|
|
107
|
+
details: [
|
|
108
|
+
`Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,
|
|
109
|
+
input.young ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.' : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',
|
|
110
|
+
'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',
|
|
111
|
+
'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\'s payouts in Stripe, then close this row with what you did.'
|
|
112
|
+
].join('\n'),
|
|
113
|
+
reporterEmail: null,
|
|
114
|
+
reporterName: null,
|
|
115
|
+
dmca: null,
|
|
116
|
+
reportCount: FieldValue.increment(1),
|
|
117
|
+
paymentVelocity: {
|
|
118
|
+
hostId: input.hostId,
|
|
119
|
+
orgId: input.orgId,
|
|
120
|
+
day,
|
|
121
|
+
threshold: window.limit,
|
|
122
|
+
windowMinutes: minutes,
|
|
123
|
+
young: input.young
|
|
124
|
+
},
|
|
125
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
126
|
+
}, first ? {
|
|
127
|
+
status: 'open',
|
|
128
|
+
createdAt: FieldValue.serverTimestamp()
|
|
129
|
+
} : {}), {
|
|
130
|
+
merge: true
|
|
131
|
+
});
|
|
132
|
+
if (first) {
|
|
133
|
+
await notifyCardTestingVelocity({
|
|
134
|
+
hostId: input.hostId,
|
|
135
|
+
orgId: input.orgId,
|
|
136
|
+
reference
|
|
137
|
+
}, {
|
|
138
|
+
notifyStaff: input.notify,
|
|
139
|
+
notifyManagers: input.notifyManagers
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
reviewId,
|
|
144
|
+
first
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('[card-payment-velocity] alarm row failed', error);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The (site, day) alarms this process has filed. */ const filedAlarms = new Set();
|
|
152
|
+
/** Forget which alarms this process filed. */ export function resetCardPaymentAlarmsForTests() {
|
|
153
|
+
filedAlarms.clear();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Count one opened card payment and return a 429 once a visitor's or an
|
|
157
|
+
* address's window is spent. See the policy module for the three counters.
|
|
158
|
+
*/ export async function cardPaymentVelocityRefusal(options) {
|
|
159
|
+
var _options_nowMs;
|
|
160
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
161
|
+
const ip = clientIp(options.request);
|
|
162
|
+
const visitorWindow = options.young ? CARD_PAYMENT_VELOCITY.perVisitorYoung : CARD_PAYMENT_VELOCITY.perVisitor;
|
|
163
|
+
const count = (key, window)=>consumeRateLimit(key, {
|
|
164
|
+
limit: window.limit,
|
|
165
|
+
windowMs: window.windowMs,
|
|
166
|
+
now: nowMs,
|
|
167
|
+
firestore: options.firestore
|
|
168
|
+
});
|
|
169
|
+
const siteKey = cardPaymentSiteKey(options.hostId);
|
|
170
|
+
const [visitor, address, site] = await Promise.all([
|
|
171
|
+
count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),
|
|
172
|
+
count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),
|
|
173
|
+
siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null)
|
|
174
|
+
]);
|
|
175
|
+
// The site counter's overage files the day's alarm. Each process files a
|
|
176
|
+
// (site, day) once — an attack is thousands of overages, and one row that
|
|
177
|
+
// says so is the whole message; the row id makes a second process's filing
|
|
178
|
+
// count on the same row rather than add one.
|
|
179
|
+
const day = cardPaymentAlarmDay(nowMs);
|
|
180
|
+
const filedKey = `${options.hostId}:${day}`;
|
|
181
|
+
if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {
|
|
182
|
+
var _options_firestore, _options_orgId, _options_notify, _options_notifyManagers;
|
|
183
|
+
filedAlarms.add(filedKey);
|
|
184
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
185
|
+
await recordCardPaymentAlarm({
|
|
186
|
+
hostId: options.hostId,
|
|
187
|
+
orgId: (_options_orgId = options.orgId) != null ? _options_orgId : null,
|
|
188
|
+
path: options.path,
|
|
189
|
+
young: options.young === true,
|
|
190
|
+
nowMs,
|
|
191
|
+
firestore,
|
|
192
|
+
notify: (_options_notify = options.notify) != null ? _options_notify : notifyStaff,
|
|
193
|
+
notifyManagers: (_options_notifyManagers = options.notifyManagers) != null ? _options_notifyManagers : notifyHostManagers
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (!visitor.allowed) return refusal(visitor.resetMs, nowMs);
|
|
197
|
+
if (!address.allowed) return refusal(address.resetMs, nowMs);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
export default cardPaymentVelocityRefusal;
|
|
201
|
+
|
|
202
|
+
//# sourceMappingURL=card-payment-velocity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyHostManagers, notifyStaff } from './notifications'\nimport { consumeRateLimit } from './rate-limit-store'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyStaff`. */\n notify?: typeof notifyStaff\n /** Injectable for tests; defaults to `notifyHostManagers`. */\n notifyManagers?: typeof notifyHostManagers\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * Tell staff and the site's managers, once per site per day, that its payment\n * doors crossed the card-testing alarm (AGL-3363). The one notifier for this\n * kind of signal, so a shared risk-notice seam can take it over whole.\n *\n * The merchant's sentence names the outcome and what to do, never the rule\n * or its numbers: a merchant who is the card tester must not learn how to\n * pace under it. Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string },\n deps: {\n notifyStaff: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\n },\n): Promise<void> {\n await Promise.all([\n deps\n .notifyStaff({\n type: 'system.abuseReportUrgent',\n title: `Card-testing velocity — site ${input.hostId}`,\n body:\n 'An unusual number of card payments opened on one site' +\n (input.orgId ? ` (workspace ${input.orgId})` : '') +\n '. Nothing has been refused site-wide or refunded. Check the site and its ' +\n 'recent orders; if it is card testing, lock the workspace and pause the ' +\n `connected account's payouts. Reference ${input.reference}.`,\n link: '/admin/abuse-reports',\n })\n .catch(() => undefined),\n deps\n .notifyManagers(input.hostId, {\n type: 'content.order',\n title: 'Unusual checkout activity on your site',\n body:\n 'Your checkout saw an unusual burst of payment attempts, which can be ' +\n 'someone testing stolen cards. Checkout is still open; repeated attempts ' +\n 'from one source are being slowed. Review recent orders before you ' +\n 'fulfill them, and refund any you do not recognize. If this was a launch ' +\n `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,\n link: `/${input.hostId}/orders`,\n })\n .catch(() => undefined),\n ])\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notify: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n { hostId: input.hostId, orgId: input.orgId, reference },\n { notifyStaff: input.notify, notifyManagers: input.notifyManagers },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notify: options.notify ?? notifyStaff,\n notifyManagers: options.notifyManagers ?? notifyHostManagers,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","notifyHostManagers","notifyStaff","consumeRateLimit","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","Promise","all","type","title","body","orgId","reference","link","catch","undefined","notifyManagers","recordCardPaymentAlarm","reviewId","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","path","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","notify","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { resolvePublicIp } from './serve-plugin-fetch';
|
|
18
|
+
/** Why a configured URL was not called. */
|
|
19
|
+
export type ConfiguredUrlRefusal = 'malformed' | 'not-https' | 'credentials' | 'port' | 'lookalike' | 'private-address';
|
|
20
|
+
/** The checks that need no network: shape, scheme, port, credentials, brand. */
|
|
21
|
+
export declare function configuredUrlRefusal(url: unknown): ConfiguredUrlRefusal | null;
|
|
22
|
+
/** What the merchant is told a refusal means, in their words. */
|
|
23
|
+
export declare function describeConfiguredUrlRefusal(refusal: ConfiguredUrlRefusal): string;
|
|
24
|
+
export type ConfiguredUrlFetchResult =
|
|
25
|
+
/** Called: the status it answered. The body is discarded. */
|
|
26
|
+
{
|
|
27
|
+
ok: true;
|
|
28
|
+
status: number;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
refusal: ConfiguredUrlRefusal;
|
|
32
|
+
host: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Call `url` under the rules above and answer its status; the body is
|
|
36
|
+
* discarded, because a notification's receiver answers with a status and
|
|
37
|
+
* nothing a caller should read. Never follows a redirect. Throws only what
|
|
38
|
+
* `fetch` throws (a timeout, a reset), for the caller to retry.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fetchConfiguredPublicUrl(url: string, init: Omit<RequestInit, 'redirect'>, deps?: {
|
|
41
|
+
resolve?: typeof resolvePublicIp;
|
|
42
|
+
}): Promise<ConfiguredUrlFetchResult>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*==========================================
|
|
18
|
+
* A SERVER CALL TO A URL A TENANT TYPED (AGL-3363).
|
|
19
|
+
*
|
|
20
|
+
* Some features call an address the merchant configured, from the
|
|
21
|
+
* platform's own servers: a dropship supplier's order webhook is the first.
|
|
22
|
+
* That address is author data, so it gets the checks the plugin fetch proxy
|
|
23
|
+
* (`serve-plugin-fetch.ts`, AGL-515/1881) gives a plugin's declared origin,
|
|
24
|
+
* through the same two primitives, rather than a bare `fetch`:
|
|
25
|
+
*
|
|
26
|
+
* - https only, on the default port, with no credentials in the authority;
|
|
27
|
+
* - a host that wears a brand it is not (`lookalikeBrandForHost`) is refused
|
|
28
|
+
* for every workspace, the phishing screen's strong tier;
|
|
29
|
+
* - the name must resolve to PUBLIC addresses only, and the connection is
|
|
30
|
+
* pinned to the address that was checked (`resolvePublicIp` +
|
|
31
|
+
* `createPinnedDispatcher`), so a name cannot be rebound to the cloud
|
|
32
|
+
* metadata endpoint or a private range between the check and the connect;
|
|
33
|
+
* - a redirect is not followed (`redirect: 'manual'`): a 3xx is the answer,
|
|
34
|
+
* which a caller that wants a 2xx treats as a failure.
|
|
35
|
+
*
|
|
36
|
+
* This sits beside the plugin fetch proxy and is not it: that one proxies a
|
|
37
|
+
* browser's request to an allowlisted origin and streams the reply; this is
|
|
38
|
+
* a server's own call with no allowlist but the rules above.
|
|
39
|
+
*=========================================*/ import { lookalikeBrandForHost } from "@aglyn/shared-util-email/outbound-phishing-screen";
|
|
40
|
+
import { createPinnedDispatcher, resolvePublicIp } from "./serve-plugin-fetch.js";
|
|
41
|
+
/** The checks that need no network: shape, scheme, port, credentials, brand. */ export function configuredUrlRefusal(url) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(String(url != null ? url : ''));
|
|
45
|
+
} catch (unused) {
|
|
46
|
+
return 'malformed';
|
|
47
|
+
}
|
|
48
|
+
if (parsed.protocol !== 'https:') return 'not-https';
|
|
49
|
+
if (parsed.username || parsed.password) return 'credentials';
|
|
50
|
+
if (parsed.port && parsed.port !== '443') return 'port';
|
|
51
|
+
if (lookalikeBrandForHost(parsed.hostname)) return 'lookalike';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** What the merchant is told a refusal means, in their words. */ export function describeConfiguredUrlRefusal(refusal) {
|
|
55
|
+
switch(refusal){
|
|
56
|
+
case 'malformed':
|
|
57
|
+
return 'the address is not a valid web address';
|
|
58
|
+
case 'not-https':
|
|
59
|
+
return 'the address must start with https://';
|
|
60
|
+
case 'credentials':
|
|
61
|
+
return 'the address must not contain a user name or password';
|
|
62
|
+
case 'port':
|
|
63
|
+
return 'the address must use the standard https port';
|
|
64
|
+
case 'lookalike':
|
|
65
|
+
return 'the address looks like another company’s website';
|
|
66
|
+
case 'private-address':
|
|
67
|
+
return 'the address does not lead to a public server';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Call `url` under the rules above and answer its status; the body is
|
|
72
|
+
* discarded, because a notification's receiver answers with a status and
|
|
73
|
+
* nothing a caller should read. Never follows a redirect. Throws only what
|
|
74
|
+
* `fetch` throws (a timeout, a reset), for the caller to retry.
|
|
75
|
+
*/ export async function fetchConfiguredPublicUrl(url, init, deps = {}) {
|
|
76
|
+
var _deps_resolve;
|
|
77
|
+
const refusal = configuredUrlRefusal(url);
|
|
78
|
+
let host;
|
|
79
|
+
try {
|
|
80
|
+
host = new URL(url).hostname;
|
|
81
|
+
} catch (unused) {
|
|
82
|
+
host = '';
|
|
83
|
+
}
|
|
84
|
+
if (refusal) return {
|
|
85
|
+
ok: false,
|
|
86
|
+
refusal,
|
|
87
|
+
host
|
|
88
|
+
};
|
|
89
|
+
const pinned = await ((_deps_resolve = deps.resolve) != null ? _deps_resolve : resolvePublicIp)(host);
|
|
90
|
+
if (!pinned) return {
|
|
91
|
+
ok: false,
|
|
92
|
+
refusal: 'private-address',
|
|
93
|
+
host
|
|
94
|
+
};
|
|
95
|
+
const dispatcher = createPinnedDispatcher(pinned);
|
|
96
|
+
try {
|
|
97
|
+
var _response_status;
|
|
98
|
+
var _response_body;
|
|
99
|
+
// `dispatcher` is undici's, which the global fetch honors; spread from a
|
|
100
|
+
// variable so the DOM `RequestInit` type does not reject the field.
|
|
101
|
+
const pinnedInit = _extends({}, init, {
|
|
102
|
+
redirect: 'manual',
|
|
103
|
+
dispatcher
|
|
104
|
+
});
|
|
105
|
+
const response = await fetch(url, pinnedInit);
|
|
106
|
+
// Released before the dispatcher closes: `close` waits for requests in
|
|
107
|
+
// flight, and an unread body is one.
|
|
108
|
+
await ((_response_body = response.body) == null ? void 0 : _response_body.cancel().catch(()=>undefined));
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
status: Number((_response_status = response.status) != null ? _response_status : 0)
|
|
112
|
+
};
|
|
113
|
+
} finally{
|
|
114
|
+
await dispatcher.close().catch(()=>undefined);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=configured-url-fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/configured-url-fetch.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A SERVER CALL TO A URL A TENANT TYPED (AGL-3363).\n *\n * Some features call an address the merchant configured, from the\n * platform's own servers: a dropship supplier's order webhook is the first.\n * That address is author data, so it gets the checks the plugin fetch proxy\n * (`serve-plugin-fetch.ts`, AGL-515/1881) gives a plugin's declared origin,\n * through the same two primitives, rather than a bare `fetch`:\n *\n * - https only, on the default port, with no credentials in the authority;\n * - a host that wears a brand it is not (`lookalikeBrandForHost`) is refused\n * for every workspace, the phishing screen's strong tier;\n * - the name must resolve to PUBLIC addresses only, and the connection is\n * pinned to the address that was checked (`resolvePublicIp` +\n * `createPinnedDispatcher`), so a name cannot be rebound to the cloud\n * metadata endpoint or a private range between the check and the connect;\n * - a redirect is not followed (`redirect: 'manual'`): a 3xx is the answer,\n * which a caller that wants a 2xx treats as a failure.\n *\n * This sits beside the plugin fetch proxy and is not it: that one proxies a\n * browser's request to an allowlisted origin and streams the reply; this is\n * a server's own call with no allowlist but the rules above.\n *=========================================*/\n\nimport { lookalikeBrandForHost } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { createPinnedDispatcher, resolvePublicIp } from './serve-plugin-fetch'\n\n/** Why a configured URL was not called. */\nexport type ConfiguredUrlRefusal =\n | 'malformed'\n | 'not-https'\n | 'credentials'\n | 'port'\n | 'lookalike'\n | 'private-address'\n\n/** The checks that need no network: shape, scheme, port, credentials, brand. */\nexport function configuredUrlRefusal(url: unknown): ConfiguredUrlRefusal | null {\n let parsed: URL\n try {\n parsed = new URL(String(url ?? ''))\n } catch {\n return 'malformed'\n }\n if (parsed.protocol !== 'https:') return 'not-https'\n if (parsed.username || parsed.password) return 'credentials'\n if (parsed.port && parsed.port !== '443') return 'port'\n if (lookalikeBrandForHost(parsed.hostname)) return 'lookalike'\n return null\n}\n\n/** What the merchant is told a refusal means, in their words. */\nexport function describeConfiguredUrlRefusal(refusal: ConfiguredUrlRefusal): string {\n switch (refusal) {\n case 'malformed':\n return 'the address is not a valid web address'\n case 'not-https':\n return 'the address must start with https://'\n case 'credentials':\n return 'the address must not contain a user name or password'\n case 'port':\n return 'the address must use the standard https port'\n case 'lookalike':\n return 'the address looks like another company’s website'\n case 'private-address':\n return 'the address does not lead to a public server'\n }\n}\n\nexport type ConfiguredUrlFetchResult =\n /** Called: the status it answered. The body is discarded. */\n | { ok: true; status: number }\n | { ok: false; refusal: ConfiguredUrlRefusal; host: string }\n\n/**\n * Call `url` under the rules above and answer its status; the body is\n * discarded, because a notification's receiver answers with a status and\n * nothing a caller should read. Never follows a redirect. Throws only what\n * `fetch` throws (a timeout, a reset), for the caller to retry.\n */\nexport async function fetchConfiguredPublicUrl(\n url: string,\n init: Omit<RequestInit, 'redirect'>,\n deps: { resolve?: typeof resolvePublicIp } = {},\n): Promise<ConfiguredUrlFetchResult> {\n const refusal = configuredUrlRefusal(url)\n let host: string\n try {\n host = new URL(url).hostname\n } catch {\n host = ''\n }\n if (refusal) return { ok: false, refusal, host }\n const pinned = await (deps.resolve ?? resolvePublicIp)(host)\n if (!pinned) return { ok: false, refusal: 'private-address', host }\n const dispatcher = createPinnedDispatcher(pinned)\n try {\n // `dispatcher` is undici's, which the global fetch honors; spread from a\n // variable so the DOM `RequestInit` type does not reject the field.\n const pinnedInit = { ...init, redirect: 'manual' as const, dispatcher }\n const response = await fetch(url, pinnedInit as RequestInit)\n // Released before the dispatcher closes: `close` waits for requests in\n // flight, and an unread body is one.\n await response.body?.cancel().catch(() => undefined)\n return { ok: true, status: Number(response.status ?? 0) }\n } finally {\n await dispatcher.close().catch(() => undefined)\n }\n}\n"],"names":["lookalikeBrandForHost","createPinnedDispatcher","resolvePublicIp","configuredUrlRefusal","url","parsed","URL","String","protocol","username","password","port","hostname","describeConfiguredUrlRefusal","refusal","fetchConfiguredPublicUrl","init","deps","host","ok","pinned","resolve","dispatcher","response","pinnedInit","redirect","fetch","body","cancel","catch","undefined","status","Number","close"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;2CAsB2C,GAE3C,SAASA,qBAAqB,QAAQ,oDAAmD;AACzF,SAASC,sBAAsB,EAAEC,eAAe,QAAQ,0BAAsB;AAW9E,8EAA8E,GAC9E,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAIC;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIC,OAAOH,cAAAA,MAAO;IACjC,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAIC,OAAOG,QAAQ,KAAK,UAAU,OAAO;IACzC,IAAIH,OAAOI,QAAQ,IAAIJ,OAAOK,QAAQ,EAAE,OAAO;IAC/C,IAAIL,OAAOM,IAAI,IAAIN,OAAOM,IAAI,KAAK,OAAO,OAAO;IACjD,IAAIX,sBAAsBK,OAAOO,QAAQ,GAAG,OAAO;IACnD,OAAO;AACT;AAEA,+DAA+D,GAC/D,OAAO,SAASC,6BAA6BC,OAA6B;IACxE,OAAQA;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;IACX;AACF;AAOA;;;;;CAKC,GACD,OAAO,eAAeC,yBACpBX,GAAW,EACXY,IAAmC,EACnCC,OAA6C,CAAC,CAAC;QAUzBA;IARtB,MAAMH,UAAUX,qBAAqBC;IACrC,IAAIc;IACJ,IAAI;QACFA,OAAO,IAAIZ,IAAIF,KAAKQ,QAAQ;IAC9B,EAAE,eAAM;QACNM,OAAO;IACT;IACA,IAAIJ,SAAS,OAAO;QAAEK,IAAI;QAAOL;QAASI;IAAK;IAC/C,MAAME,SAAS,MAAM,EAACH,gBAAAA,KAAKI,OAAO,YAAZJ,gBAAgBf,iBAAiBgB;IACvD,IAAI,CAACE,QAAQ,OAAO;QAAED,IAAI;QAAOL,SAAS;QAAmBI;IAAK;IAClE,MAAMI,aAAarB,uBAAuBmB;IAC1C,IAAI;YAQgCG;YAD5BA;QANN,yEAAyE;QACzE,oEAAoE;QACpE,MAAMC,aAAa,aAAKR;YAAMS,UAAU;YAAmBH;;QAC3D,MAAMC,WAAW,MAAMG,MAAMtB,KAAKoB;QAClC,uEAAuE;QACvE,qCAAqC;QACrC,QAAMD,iBAAAA,SAASI,IAAI,qBAAbJ,eAAeK,MAAM,GAAGC,KAAK,CAAC,IAAMC;QAC1C,OAAO;YAAEX,IAAI;YAAMY,QAAQC,QAAOT,mBAAAA,SAASQ,MAAM,YAAfR,mBAAmB;QAAG;IAC1D,SAAU;QACR,MAAMD,WAAWW,KAAK,GAAGJ,KAAK,CAAC,IAAMC;IACvC;AACF"}
|
|
@@ -51,3 +51,10 @@ export interface MxRecord {
|
|
|
51
51
|
}
|
|
52
52
|
/** MX records at `host`, exchanges lowercased and stripped of a trailing dot. */
|
|
53
53
|
export declare function lookupMx(host: string): Promise<DnsLookupResult<MxRecord>>;
|
|
54
|
+
/**
|
|
55
|
+
* Whether `host` has an address record, A or AAAA — the implicit MX a domain
|
|
56
|
+
* with no MX is still delivered to (RFC 5321 §5.1). `records` holds the
|
|
57
|
+
* addresses found; empty on a conclusive "none". Unanswered only when
|
|
58
|
+
* neither family could be asked.
|
|
59
|
+
*/
|
|
60
|
+
export declare function lookupAddress(host: string): Promise<DnsLookupResult<string>>;
|
|
@@ -180,5 +180,57 @@
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether `host` has an address record, A or AAAA — the implicit MX a domain
|
|
185
|
+
* with no MX is still delivered to (RFC 5321 §5.1). `records` holds the
|
|
186
|
+
* addresses found; empty on a conclusive "none". Unanswered only when
|
|
187
|
+
* neither family could be asked.
|
|
188
|
+
*/ export async function lookupAddress(host) {
|
|
189
|
+
const ask = async (family)=>{
|
|
190
|
+
try {
|
|
191
|
+
const resolver = new CallbackResolver();
|
|
192
|
+
resolver.setServers(PUBLIC_DNS_RESOLVERS);
|
|
193
|
+
const records = await new Promise((resolve, reject)=>{
|
|
194
|
+
const done = (error, addresses)=>error ? reject(error) : resolve(addresses);
|
|
195
|
+
if (family === 4) resolver.resolve4(host, done);
|
|
196
|
+
else resolver.resolve6(host, done);
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
answered: true,
|
|
200
|
+
records
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const code = error == null ? void 0 : error.code;
|
|
204
|
+
if (isConclusiveDnsCode(code)) return {
|
|
205
|
+
answered: true,
|
|
206
|
+
records: []
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
return {
|
|
210
|
+
answered: true,
|
|
211
|
+
records: family === 4 ? await dns.resolve4(host) : await dns.resolve6(host)
|
|
212
|
+
};
|
|
213
|
+
} catch (fallbackError) {
|
|
214
|
+
const fallbackCode = fallbackError == null ? void 0 : fallbackError.code;
|
|
215
|
+
if (isConclusiveDnsCode(fallbackCode)) return {
|
|
216
|
+
answered: true,
|
|
217
|
+
records: []
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
answered: false,
|
|
221
|
+
records: []
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const v4 = await ask(4);
|
|
227
|
+
if (v4.records.length) return v4;
|
|
228
|
+
const v6 = await ask(6);
|
|
229
|
+
if (v6.records.length) return v6;
|
|
230
|
+
return {
|
|
231
|
+
answered: v4.answered && v6.answered,
|
|
232
|
+
records: []
|
|
233
|
+
};
|
|
234
|
+
}
|
|
183
235
|
|
|
184
236
|
//# sourceMappingURL=dns-probe.js.map
|