@rodit/rodit-auth-be 9.14.0 → 9.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +132 -41
- package/index.js +10 -6
- package/lib/auth/authentication.js +0 -140
- package/lib/auth/tokenservice.js +1 -45
- package/lib/middleware/authenticationmw.js +13 -2
- package/lib/middleware/enforceratelimitmw.js +135 -0
- package/lib/middleware/validatepermissionsmw.js +1 -1
- package/lib/middleware/webhookhandlermw.js +370 -14
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@rodit/rodit-auth-be` are documented here.
|
|
4
4
|
|
|
5
|
+
## [9.16.0] — 2026-09-14
|
|
6
|
+
|
|
7
|
+
### Removed
|
|
8
|
+
|
|
9
|
+
- **`verify_rodit_isactive`** — DNS TXT revocation check
|
|
10
|
+
(`<token_id>.revoked.<domain>`). The function short-circuited with an
|
|
11
|
+
unconditional `return true` while debugging, so its body was unreachable and
|
|
12
|
+
no RODiT could be rejected as revoked. Removed along with its call sites in
|
|
13
|
+
`verify_peer_rodit` and `thorough_validate_jwt_token_be`, and the
|
|
14
|
+
`RODIT_REVOKED` / `SERVER_RODIT_REVOKED` failure codes they emitted.
|
|
15
|
+
No behavior change; the SDK now has no revocation check.
|
|
16
|
+
`verify_rodit_isactive_fe` in `@rodit/rodit-auth-fe` is unaffected.
|
|
17
|
+
|
|
18
|
+
## [9.15.0] — 2026-08-11
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Outbound webhook SSRF controls** in `send_webhook` (CC-0001 residual):
|
|
23
|
+
before `fetch`, reject userinfo and private / loopback / link-local / ULA /
|
|
24
|
+
CGNAT / cloud-metadata hostnames and resolved A/AAAA addresses (same policy
|
|
25
|
+
as SLC `url-join-identity.js`); enforce JWT/peer `rodit_webhookcidr` when
|
|
26
|
+
present; resolve-once and pin the IP for the request via undici `lookup`.
|
|
27
|
+
`SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` is unchanged for **public**
|
|
28
|
+
self-signed destinations.
|
|
29
|
+
- **`req.authenticatedRoditId` + login JSON `roditid`** after successful
|
|
30
|
+
`login_client` / `login_client_withnep413` signature verification (server-
|
|
31
|
+
derived peer token id only).
|
|
32
|
+
- **`enforceRateLimitFromClaims()`** middleware (and
|
|
33
|
+
`RoditClient#getClaimRateLimitMiddleware`) that consumes `req.rateLimit`
|
|
34
|
+
set by `validatepermissions`.
|
|
35
|
+
|
|
36
|
+
### Security
|
|
37
|
+
|
|
38
|
+
- Compromised or DNS-rebound JWT `rodit_webhookurl` values can no longer reach
|
|
39
|
+
internal targets through the SDK outbound webhook path.
|
|
40
|
+
|
|
41
|
+
## [9.14.1] — 2026-08-07
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Package version bumped to **9.14.1** (local `package.json` version restored).
|
|
46
|
+
|
|
5
47
|
## [9.14.0] — 2026-07-16
|
|
6
48
|
|
|
7
49
|
### Removed
|
package/README.md
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rodit/rodit-auth-be
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js / Express SDK for RODiT-based mutual authentication, authorization, session management, and webhooks.
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
**
|
|
7
|
-
**Author:** Discernible IO
|
|
5
|
+
**npm:** [https://www.npmjs.com/package/@rodit/rodit-auth-be](https://www.npmjs.com/package/@rodit/rodit-auth-be)
|
|
6
|
+
**Monorepo:** [discernible-io/rodit-sdk](https://github.com/discernible-io/rodit-sdk) (this directory)
|
|
7
|
+
**Version:** 9.15.0 · **License:** Proprietary · **Author:** Discernible IO
|
|
8
|
+
|
|
9
|
+
## Production deployments
|
|
10
|
+
|
|
11
|
+
This package is the server runtime behind RODiT-powered APIs, including:
|
|
12
|
+
|
|
13
|
+
| Deployment | Role |
|
|
14
|
+
|------------|------|
|
|
15
|
+
| [api.identyclaw.com](https://api.identyclaw.com) | IdentyClaw platform API ([source: api-idc](https://github.com/discernible-io/api-idc)) |
|
|
16
|
+
| [api.lastcradle.io](https://api.lastcradle.io) | Last Cradle family API |
|
|
17
|
+
| Custom / federated APIs | Same SDK; sibling APIs in one token family support [federated login](#federated-login-same-family-different-api-url) |
|
|
18
|
+
|
|
19
|
+
Browser and wallet clients for these APIs use the companion package [`@rodit/rodit-auth-fe`](https://www.npmjs.com/package/@rodit/rodit-auth-fe).
|
|
8
20
|
|
|
9
21
|
**Login `POST` /api/login:** Use **`accountid`** (or **`roditid`**), **`timestamp`**, and **`base64url_signature`**. Sign UTF-8 bytes of `identifier + timestamp_iso`, and reject deprecated keys such as **`signature`** and **`account_id`**. See [CHANGELOG.md](./CHANGELOG.md).
|
|
10
22
|
|
|
23
|
+
**9.15.0:** Outbound `send_webhook` SSRF controls (private/metadata blocklist, `webhook_cidr` enforcement, DNS pin); login success exposes verified peer id as JSON `roditid` and `req.authenticatedRoditId`; optional `enforceRateLimitFromClaims()` for per-route claim quotas. See [CHANGELOG.md](./CHANGELOG.md).
|
|
24
|
+
|
|
11
25
|
**9.13 federated login:** A client RODiT issued for API A can log into API B in the same SR/CR family via `login_server({ apiEndpoint })`. See [Federated login](#federated-login-same-family-different-api-url).
|
|
12
26
|
|
|
13
27
|
## Table of Contents
|
|
@@ -23,6 +37,7 @@ A comprehensive Node.js SDK for implementing RODiT-based mutual authentication,
|
|
|
23
37
|
- [Session lifetime and TTL](#session-lifetime-and-ttl)
|
|
24
38
|
- [Configuration](#configuration)
|
|
25
39
|
- [Environment Variables](#environment-variables)
|
|
40
|
+
- [Dynamic Rate Limiting](#dynamic-rate-limiting)
|
|
26
41
|
- [Session Storage Configuration](#session-storage-configuration)
|
|
27
42
|
- [Configuration Priority](#configuration-priority)
|
|
28
43
|
- [Logging & Monitoring](#logging--monitoring)
|
|
@@ -605,26 +620,33 @@ Permissions are configured in your RODiT token metadata using the `permissioned_
|
|
|
605
620
|
```
|
|
606
621
|
|
|
607
622
|
**Permission Format:**
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
623
|
+
|
|
624
|
+
Values are strings: optional scope prefix (`+` / `-`) plus a numeric rate:
|
|
625
|
+
|
|
626
|
+
| Value | Meaning |
|
|
627
|
+
|-------|---------|
|
|
628
|
+
| `"+0"` | Allowed (entity + properties scope); **unlimited** rate |
|
|
629
|
+
| `"+60"` | Allowed; **60 requests** per window (default window 60s) |
|
|
630
|
+
| `"-0"` | Allowed (properties-only scope); unlimited |
|
|
631
|
+
| (absent / no match) | Denied (`403`) |
|
|
632
|
+
|
|
633
|
+
`validatepermissions` / `authorize` checks allow/deny and sets `req.rateLimit` from the numeric part. That metadata is inert until you mount `enforceRateLimitFromClaims()` (see [Dynamic Rate Limiting](#dynamic-rate-limiting)).
|
|
612
634
|
|
|
613
635
|
### Permission Validation Middleware
|
|
614
636
|
|
|
615
637
|
The `authorize` middleware validates that the authenticated user has permission to access the requested route:
|
|
616
638
|
|
|
617
639
|
```javascript
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
// Apply both authentication and authorization
|
|
640
|
+
// After RoditClient.create('server') and app.locals.roditClient = roditClient
|
|
641
|
+
const authenticate = (req, res, next) => roditClient.authenticate(req, res, next)
|
|
642
|
+
const authorize = (req, res, next) => roditClient.authorize(req, res, next)
|
|
643
|
+
const enforceClaimLimits = roditClient.getClaimRateLimitMiddleware()
|
|
644
|
+
|
|
645
|
+
// Auth + permission allow/deny
|
|
625
646
|
app.use('/api/admin', authenticate, authorize, adminRoutes)
|
|
626
|
-
|
|
627
|
-
|
|
647
|
+
|
|
648
|
+
// Auth + permissions + per-route claim quotas from permissioned_routes
|
|
649
|
+
app.use('/api/cruda', authenticate, authorize, enforceClaimLimits(), crudaRoutes)
|
|
628
650
|
```
|
|
629
651
|
|
|
630
652
|
### Permission Enforcement
|
|
@@ -1186,16 +1208,33 @@ const dbPath = config.get('API_DEFAULT_OPTIONS.DB_PATH')
|
|
|
1186
1208
|
|
|
1187
1209
|
### Dynamic Rate Limiting
|
|
1188
1210
|
|
|
1211
|
+
Two complementary limiters:
|
|
1212
|
+
|
|
1213
|
+
1. **Global** (`getRateLimitMiddleware` / `ratelimitmw`) — from own RODiT `max_requests` / `maxrq_window` (or fixed numbers). Applies across routes.
|
|
1214
|
+
2. **Per-route claim** (`getClaimRateLimitMiddleware` / `enforceRateLimitFromClaims`) — consumes `req.rateLimit` set by `validatepermissions` from each peer’s `permissioned_routes` value (e.g. `"+60"` → 60 req / 60s for that path). Returns `429 RATE_LIMIT_EXCEEDED` when exceeded. In-memory, per process.
|
|
1215
|
+
|
|
1189
1216
|
```javascript
|
|
1190
|
-
//
|
|
1217
|
+
// 1) Global limiter from own RODiT metadata
|
|
1191
1218
|
const configObject = await roditClient.getConfigOwnRodit()
|
|
1192
1219
|
const metadata = configObject.own_rodit.metadata
|
|
1193
1220
|
if (metadata.max_requests && metadata.maxrq_window) {
|
|
1194
|
-
const maxRequests = parseInt(metadata.max_requests)
|
|
1195
|
-
const windowSeconds = parseInt(metadata.maxrq_window)
|
|
1221
|
+
const maxRequests = parseInt(metadata.max_requests, 10)
|
|
1222
|
+
const windowSeconds = parseInt(metadata.maxrq_window, 10)
|
|
1196
1223
|
const rateLimiter = roditClient.getRateLimitMiddleware()
|
|
1197
|
-
|
|
1198
|
-
|
|
1224
|
+
// Note: factory args are (maxRequests, windowMinutes)
|
|
1225
|
+
app.use(rateLimiter(maxRequests, Math.ceil(windowSeconds / 60) || 1))
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// 2) Per-route claim enforcement (after authenticate + validatepermissions)
|
|
1229
|
+
const { authenticate_apicall, validatepermissions, enforceRateLimitFromClaims } =
|
|
1230
|
+
require('@rodit/rodit-auth-be')
|
|
1231
|
+
// Or: roditClient.getClaimRateLimitMiddleware()
|
|
1232
|
+
app.use(
|
|
1233
|
+
'/api',
|
|
1234
|
+
(req, res, next) => roditClient.authenticate(req, res, next),
|
|
1235
|
+
validatepermissions,
|
|
1236
|
+
enforceRateLimitFromClaims()
|
|
1237
|
+
)
|
|
1199
1238
|
```
|
|
1200
1239
|
|
|
1201
1240
|
### Environment Variables
|
|
@@ -1249,8 +1288,8 @@ export LOKI_TLS_SKIP_VERIFY=false # true to skip TLS verification
|
|
|
1249
1288
|
|
|
1250
1289
|
#### Security Options
|
|
1251
1290
|
```bash
|
|
1252
|
-
// Webhook
|
|
1253
|
-
export
|
|
1291
|
+
// Webhook TLS (public destinations only; private/metadata URLs are always blocked)
|
|
1292
|
+
export SECURITY_OPTIONS_WEBHOOK_TLS_SKIP_VERIFY=false # true for self-signed public webhook hosts
|
|
1254
1293
|
// Login mode control (see Login Mode section below)
|
|
1255
1294
|
export SECURITY_OPTIONS_LOGIN_MODE=partner # partner, promiscuous, or p2p
|
|
1256
1295
|
// Security thresholds
|
|
@@ -1487,26 +1526,40 @@ performanceService.recordMetric('user_action', 1, {
|
|
|
1487
1526
|
})
|
|
1488
1527
|
```
|
|
1489
1528
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
### Overview
|
|
1529
|
+
## Webhooks
|
|
1493
1530
|
|
|
1494
|
-
|
|
1531
|
+
### Overview
|
|
1532
|
+
|
|
1533
|
+
The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs come from the **peer JWT** claim `rodit_webhookurl` (from that peer’s RODiT `webhook_url` metadata).
|
|
1495
1534
|
|
|
1496
1535
|
**Key Features:**
|
|
1497
1536
|
- **Custom Endpoints** - Send webhooks to any endpoint path (e.g., `/hooks/wake`, `/hooks/agent`, `/webhook`)
|
|
1498
|
-
- **
|
|
1499
|
-
- **
|
|
1537
|
+
- **Ed25519 signed** - Payload + timestamp signed; receivers verify via `X-Signature` / signer identity headers
|
|
1538
|
+
- **Outbound SSRF controls (9.15+)** - Before `fetch`, reject userinfo and private / loopback / link-local / ULA / CGNAT / cloud-metadata hostnames and resolved A/AAAA; enforce peer `rodit_webhookcidr` when set; resolve-once and pin DNS for the request
|
|
1539
|
+
- **Self-signed TLS (public only)** - `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true` still allowed for **public** destinations
|
|
1500
1540
|
|
|
1501
|
-
|
|
1541
|
+
Peer RODiT metadata:
|
|
1502
1542
|
|
|
1503
1543
|
```json
|
|
1504
1544
|
{
|
|
1505
|
-
"webhook_url": "
|
|
1506
|
-
"webhook_cidr": "0.0.0.0/0"
|
|
1545
|
+
"webhook_url": "hooks.example.com",
|
|
1546
|
+
"webhook_cidr": "0.0.0.0/0"
|
|
1507
1547
|
}
|
|
1508
1548
|
```
|
|
1509
1549
|
|
|
1550
|
+
- `webhook_url` — host (optional path); SDK POSTs `https://{webhook_url}{endpoint}`
|
|
1551
|
+
- `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
|
|
1552
|
+
|
|
1553
|
+
Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
|
|
1554
|
+
|
|
1555
|
+
| Code | Meaning |
|
|
1556
|
+
|------|---------|
|
|
1557
|
+
| `WEBHOOK_URL_MISSING` | No `rodit_webhookurl` on `req.user` |
|
|
1558
|
+
| `WEBHOOK_URL_INVALID` | Bad URL / userinfo / scheme |
|
|
1559
|
+
| `WEBHOOK_URL_BLOCKED` | Private, loopback, or metadata target |
|
|
1560
|
+
| `WEBHOOK_URL_UNRESOLVABLE` | DNS lookup failed |
|
|
1561
|
+
| `WEBHOOK_CIDR_DENIED` | Resolved IP outside peer `webhook_cidr` |
|
|
1562
|
+
|
|
1510
1563
|
### Sending Webhooks to Default Endpoint
|
|
1511
1564
|
|
|
1512
1565
|
Send webhooks to the default `/webhook` endpoint:
|
|
@@ -2079,16 +2132,24 @@ Handle Express login requests from clients. Validates RODiT credentials and issu
|
|
|
2079
2132
|
app.post('/api/login', (req, res) => roditClient.login_client(req, res))
|
|
2080
2133
|
```
|
|
2081
2134
|
|
|
2082
|
-
**Request Body:** `
|
|
2135
|
+
**Request Body:** `accountid` or `roditid` (exactly one non-empty), `timestamp` (and/or `timestamp_iso`), and `base64url_signature` (or `roditid_base64url_signature`).
|
|
2136
|
+
|
|
2137
|
+
**On success:**
|
|
2138
|
+
- Sets `req.authenticatedRoditId` to the **verified** peer token id (`peer_rodit.token_id`) — do not use `req.body.roditid` for app hooks
|
|
2139
|
+
- JSON body includes the same value as `roditid` (in addition to `jwt_token` / `requestId`)
|
|
2140
|
+
- Creates a server session keyed by that peer id; issues JWT (`New-Token` header + body)
|
|
2083
2141
|
|
|
2084
2142
|
**Response:**
|
|
2085
2143
|
```javascript
|
|
2086
2144
|
{
|
|
2087
2145
|
jwt_token: '<jwt-token>',
|
|
2088
|
-
requestId: '01HQXYZ...'
|
|
2146
|
+
requestId: '01HQXYZ...',
|
|
2147
|
+
roditid: '<verified-peer-token-id>'
|
|
2089
2148
|
}
|
|
2090
2149
|
```
|
|
2091
2150
|
|
|
2151
|
+
`login_client_withnep413` returns the same success shape (`jwt_token`, `requestId`, `roditid`) and also sets `req.authenticatedRoditId`.
|
|
2152
|
+
|
|
2092
2153
|
##### logout_client(req, res)
|
|
2093
2154
|
|
|
2094
2155
|
Handle Express logout requests. Closes session and invalidates JWT token.
|
|
@@ -2275,6 +2336,23 @@ app.use(limiter)
|
|
|
2275
2336
|
|
|
2276
2337
|
**Returns:** Express middleware function
|
|
2277
2338
|
|
|
2339
|
+
##### getClaimRateLimitMiddleware()
|
|
2340
|
+
|
|
2341
|
+
Get the middleware factory that enforces per-route limits from
|
|
2342
|
+
`req.rateLimit` (populated by `validatepermissions` / `authorize`).
|
|
2343
|
+
|
|
2344
|
+
Place **after** authentication and permission validation. Skips when
|
|
2345
|
+
`req.rateLimit` is missing or `unlimited: true`. Default window is
|
|
2346
|
+
`req.rateLimit.timeWindow` seconds (60). Optional `{ store }` Map-like
|
|
2347
|
+
override for multi-instance deployments.
|
|
2348
|
+
|
|
2349
|
+
```javascript
|
|
2350
|
+
const enforceRateLimitFromClaims = roditClient.getClaimRateLimitMiddleware()
|
|
2351
|
+
app.use('/api', authenticate, authorize, enforceRateLimitFromClaims())
|
|
2352
|
+
```
|
|
2353
|
+
|
|
2354
|
+
**Returns:** Middleware factory `(options?) => Express middleware`
|
|
2355
|
+
|
|
2278
2356
|
##### getPerformanceService()
|
|
2279
2357
|
|
|
2280
2358
|
Get the performance tracking service.
|
|
@@ -2310,7 +2388,12 @@ const webhookHandler = roditClient.getWebhookHandler()
|
|
|
2310
2388
|
|
|
2311
2389
|
##### send_webhook(payload, req)
|
|
2312
2390
|
|
|
2313
|
-
Send a webhook notification.
|
|
2391
|
+
Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
|
|
2392
|
+
|
|
2393
|
+
Outbound delivery rejects private / loopback / metadata destinations (and
|
|
2394
|
+
resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
|
|
2395
|
+
DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
|
|
2396
|
+
to **public** self-signed endpoints only.
|
|
2314
2397
|
|
|
2315
2398
|
```javascript
|
|
2316
2399
|
const result = await roditClient.send_webhook({
|
|
@@ -2318,16 +2401,23 @@ const result = await roditClient.send_webhook({
|
|
|
2318
2401
|
data: { userId: '123', action: 'login' },
|
|
2319
2402
|
isError: false
|
|
2320
2403
|
}, req)
|
|
2404
|
+
|
|
2405
|
+
if (!result.isValid) {
|
|
2406
|
+
// e.g. WEBHOOK_URL_BLOCKED, WEBHOOK_CIDR_DENIED, WEBHOOK_SEND_ERROR
|
|
2407
|
+
logger.warn('Webhook not delivered', result.error)
|
|
2408
|
+
}
|
|
2321
2409
|
```
|
|
2322
2410
|
|
|
2411
|
+
Aliases: `sendWebhook(payload, req)`, `sendWebhookToEndpoint(payload, endpoint, req)`.
|
|
2412
|
+
|
|
2323
2413
|
**Parameters:**
|
|
2324
2414
|
- `payload` (Object): Webhook payload
|
|
2325
2415
|
- `event` (string): Event name
|
|
2326
2416
|
- `data` (Object): Event data
|
|
2327
2417
|
- `isError` (boolean): Whether this is an error event
|
|
2328
|
-
- `req` (Object): Express request
|
|
2418
|
+
- `req` (Object): Express request with authenticated `req.user` (peer JWT claims)
|
|
2329
2419
|
|
|
2330
|
-
**Returns:** `Promise<Object>`
|
|
2420
|
+
**Returns:** `Promise<Object>` — success `{ isValid: true, message, requestId, duration }` or `{ isValid: false, error: { code, message, requestId } }`
|
|
2331
2421
|
|
|
2332
2422
|
### Exported Components
|
|
2333
2423
|
|
|
@@ -2363,6 +2453,7 @@ const {
|
|
|
2363
2453
|
versioningMiddleware, // API versioning
|
|
2364
2454
|
loggingmw, // Logging middleware
|
|
2365
2455
|
ratelimitmw, // Rate limiting middleware
|
|
2456
|
+
enforceRateLimitFromClaims, // Consumes req.rateLimit from validatepermissions
|
|
2366
2457
|
versionManager, // Version manager
|
|
2367
2458
|
VersionManager, // Version manager class
|
|
2368
2459
|
nearorg_rpc_timestamp // Blockchain RPC timestamp function
|
|
@@ -2393,8 +2484,8 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
|
|
|
2393
2484
|
| `serviceprovider_signature` | string | Cryptographic signature for verification |
|
|
2394
2485
|
| `subjectuniqueidentifier_url` | string | Primary API service endpoint |
|
|
2395
2486
|
| `userselected_dn` | string | User-selected display name |
|
|
2396
|
-
| `webhook_cidr` | string |
|
|
2397
|
-
| `webhook_url` | string |
|
|
2487
|
+
| `webhook_cidr` | string | Allowlist CIDR(s) for outbound webhook resolved IPs (`0.0.0.0/0` = any public IP; private ranges are still blocked by SSRF controls) |
|
|
2488
|
+
| `webhook_url` | string | Peer webhook host used as `rodit_webhookurl` in issued JWTs |
|
|
2398
2489
|
|
|
2399
2490
|
## Best Practices
|
|
2400
2491
|
|
package/index.js
CHANGED
|
@@ -41,6 +41,7 @@ const { versioningMiddleware } = require('./lib/middleware/versioningmw');
|
|
|
41
41
|
const { VersionManager } = require('./services/versionmanager');
|
|
42
42
|
const loggingmw = require('./lib/middleware/loggingmw');
|
|
43
43
|
const ratelimitmw = require('./lib/middleware/ratelimitmw');
|
|
44
|
+
const enforceRateLimitFromClaims = require('./lib/middleware/enforceratelimitmw');
|
|
44
45
|
const utils = require('./services/utils');
|
|
45
46
|
const config = require('./services/configsdk');
|
|
46
47
|
const performanceService = require('./services/performanceservice');
|
|
@@ -404,6 +405,14 @@ class RoditClient {
|
|
|
404
405
|
return ratelimitmw;
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Get middleware that enforces `req.rateLimit` from validatepermissions.
|
|
410
|
+
* @returns {Function} Middleware factory: enforceRateLimitFromClaims(options?)
|
|
411
|
+
*/
|
|
412
|
+
getClaimRateLimitMiddleware() {
|
|
413
|
+
return enforceRateLimitFromClaims;
|
|
414
|
+
}
|
|
415
|
+
|
|
407
416
|
/**
|
|
408
417
|
* Get utilities
|
|
409
418
|
* @returns {Object} Utilities object
|
|
@@ -1136,9 +1145,6 @@ class RoditClient {
|
|
|
1136
1145
|
case 'RODIT_NOT_LIVE':
|
|
1137
1146
|
errorMessage += '\n→ [CLIENT REJECTED] Your RODiT is expired or not yet valid. Check the validity period.';
|
|
1138
1147
|
break;
|
|
1139
|
-
case 'RODIT_REVOKED':
|
|
1140
|
-
errorMessage += '\n→ [CLIENT REJECTED] Your RODiT has been revoked and is no longer valid.';
|
|
1141
|
-
break;
|
|
1142
1148
|
case 'SMART_CONTRACT_NOT_TRUSTED':
|
|
1143
1149
|
errorMessage += '\n→ [CLIENT REJECTED] The smart contract that issued your RODiT is not trusted by this server.';
|
|
1144
1150
|
break;
|
|
@@ -1151,9 +1157,6 @@ class RoditClient {
|
|
|
1151
1157
|
case 'SERVER_RODIT_NOT_LIVE':
|
|
1152
1158
|
errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT is expired or not yet valid. Contact the server administrator.';
|
|
1153
1159
|
break;
|
|
1154
|
-
case 'SERVER_RODIT_REVOKED':
|
|
1155
|
-
errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT has been revoked. Contact the server administrator.';
|
|
1156
|
-
break;
|
|
1157
1160
|
case 'SERVER_SMART_CONTRACT_NOT_TRUSTED':
|
|
1158
1161
|
errorMessage += '\n→ [SERVER REJECTED] The server\'s issuing smart contract is not trusted by your client. Update your trust configuration.';
|
|
1159
1162
|
break;
|
|
@@ -1950,6 +1953,7 @@ module.exports = {
|
|
|
1950
1953
|
versioningMiddleware,
|
|
1951
1954
|
loggingmw,
|
|
1952
1955
|
ratelimitmw,
|
|
1956
|
+
enforceRateLimitFromClaims,
|
|
1953
1957
|
versionManager,
|
|
1954
1958
|
VersionManager,
|
|
1955
1959
|
// Blockchain service functions
|
|
@@ -993,32 +993,6 @@ async function verify_rodit_ownership(
|
|
|
993
993
|
};
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
-
const activeStart = Date.now();
|
|
997
|
-
const isActive = await verify_rodit_isactive(
|
|
998
|
-
peer_rodit.token_id,
|
|
999
|
-
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
1000
|
-
);
|
|
1001
|
-
const activeDuration = Date.now() - activeStart;
|
|
1002
|
-
|
|
1003
|
-
logger.debug("Active verification completed", {
|
|
1004
|
-
requestId,
|
|
1005
|
-
activeDuration,
|
|
1006
|
-
isActive,
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
if (!isActive) {
|
|
1010
|
-
logger.warn("RODiT active verification failed", {
|
|
1011
|
-
requestId,
|
|
1012
|
-
roditId: peerroditid,
|
|
1013
|
-
});
|
|
1014
|
-
return {
|
|
1015
|
-
peer_rodit,
|
|
1016
|
-
goodrodit: false,
|
|
1017
|
-
failureReason: "RODIT_REVOKED",
|
|
1018
|
-
failureMessage: "RODiT has been revoked"
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
996
|
const trustedStart = Date.now();
|
|
1023
997
|
const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
|
|
1024
998
|
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
@@ -1241,119 +1215,6 @@ async function verify_rodit_ownership(
|
|
|
1241
1215
|
}
|
|
1242
1216
|
}
|
|
1243
1217
|
|
|
1244
|
-
async function verify_rodit_isactive(tokenId, ownsubjectuniqueidentifier_url) {
|
|
1245
|
-
const requestId = ulid();
|
|
1246
|
-
const startTime = Date.now();
|
|
1247
|
-
|
|
1248
|
-
// WHILE DEBUGGING TEMPORARY FIX DO NOT REMOVE THIS LINE EVER WITHOUT PERMISSION
|
|
1249
|
-
return true;
|
|
1250
|
-
|
|
1251
|
-
logger.debug("Checking RODiT activity status", {
|
|
1252
|
-
component: "RoditAuth",
|
|
1253
|
-
method: "verify_rodit_isactive",
|
|
1254
|
-
requestId,
|
|
1255
|
-
tokenId,
|
|
1256
|
-
subjectUrl: ownsubjectuniqueidentifier_url,
|
|
1257
|
-
});
|
|
1258
|
-
|
|
1259
|
-
const domainandextensionRegex =
|
|
1260
|
-
/(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/i;
|
|
1261
|
-
|
|
1262
|
-
const match = ownsubjectuniqueidentifier_url.match(domainandextensionRegex);
|
|
1263
|
-
|
|
1264
|
-
if (match) {
|
|
1265
|
-
const domainandextension = match[1];
|
|
1266
|
-
const revokingDnsEntry = `${tokenId}.revoked.${domainandextension}`;
|
|
1267
|
-
|
|
1268
|
-
logger.debug("Checking DNS revocation entry", {
|
|
1269
|
-
requestId,
|
|
1270
|
-
domain: domainandextension,
|
|
1271
|
-
revokingDnsEntry,
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
try {
|
|
1275
|
-
const dnsStart = Date.now();
|
|
1276
|
-
const resolver = new Resolver();
|
|
1277
|
-
await resolver.resolveTxt(revokingDnsEntry);
|
|
1278
|
-
const dnsDuration = Date.now() - dnsStart;
|
|
1279
|
-
const totalDuration = Date.now() - startTime;
|
|
1280
|
-
|
|
1281
|
-
logger.info("RODiT revocation found", {
|
|
1282
|
-
component: "AuthServices",
|
|
1283
|
-
method: "verify_rodit_isactive",
|
|
1284
|
-
requestId,
|
|
1285
|
-
duration: totalDuration,
|
|
1286
|
-
dnsDuration,
|
|
1287
|
-
tokenId,
|
|
1288
|
-
domain: domainandextension,
|
|
1289
|
-
revokingDnsEntry,
|
|
1290
|
-
isActive: false,
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
// Add metrics for revoked tokens
|
|
1294
|
-
logger.metric &&
|
|
1295
|
-
logger.metric("rodit_revocation_checks", totalDuration, {
|
|
1296
|
-
result: "revoked",
|
|
1297
|
-
token_id: tokenId,
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
return false;
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
// DNS error usually means no revocation entry found, which is good
|
|
1303
|
-
const dnsDuration = Date.now() - dnsStart || 0;
|
|
1304
|
-
const totalDuration = Date.now() - startTime;
|
|
1305
|
-
|
|
1306
|
-
logger.debug("No revocation found for RODiT", {
|
|
1307
|
-
requestId,
|
|
1308
|
-
dnsDuration,
|
|
1309
|
-
tokenId,
|
|
1310
|
-
error: error.code,
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
logger.info("RODiT is active", {
|
|
1314
|
-
component: "AuthServices",
|
|
1315
|
-
method: "verify_rodit_isactive",
|
|
1316
|
-
requestId,
|
|
1317
|
-
duration: totalDuration,
|
|
1318
|
-
dnsDuration,
|
|
1319
|
-
tokenId,
|
|
1320
|
-
domain: domainandextension,
|
|
1321
|
-
isActive: true,
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
// Add metrics for active tokens
|
|
1325
|
-
logger.metric &&
|
|
1326
|
-
logger.metric("rodit_revocation_checks", totalDuration, {
|
|
1327
|
-
result: "active",
|
|
1328
|
-
token_id: tokenId,
|
|
1329
|
-
});
|
|
1330
|
-
|
|
1331
|
-
return true;
|
|
1332
|
-
}
|
|
1333
|
-
} else {
|
|
1334
|
-
const duration = Date.now() - startTime;
|
|
1335
|
-
|
|
1336
|
-
logger.warn("Unable to parse domain from URL", {
|
|
1337
|
-
component: "AuthServices",
|
|
1338
|
-
method: "verify_rodit_isactive",
|
|
1339
|
-
requestId,
|
|
1340
|
-
duration,
|
|
1341
|
-
tokenId,
|
|
1342
|
-
subjectUrl: ownsubjectuniqueidentifier_url,
|
|
1343
|
-
});
|
|
1344
|
-
|
|
1345
|
-
// Add metrics for parsing errors
|
|
1346
|
-
logger.metric &&
|
|
1347
|
-
logger.metric("rodit_revocation_checks", duration, {
|
|
1348
|
-
result: "parse_error",
|
|
1349
|
-
token_id: tokenId,
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
// Default to allowing the token if domain parsing fails
|
|
1353
|
-
return true;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
1218
|
async function verify_rodit_istrusted_issuingsmartcontract(
|
|
1358
1219
|
ownsubjectuniqueidentifier_url
|
|
1359
1220
|
) {
|
|
@@ -1963,7 +1824,6 @@ module.exports = {
|
|
|
1963
1824
|
resolve_peer_rodit_for_login,
|
|
1964
1825
|
verify_peer_rodit,
|
|
1965
1826
|
validateTimestamp,
|
|
1966
|
-
verify_rodit_isactive,
|
|
1967
1827
|
verify_rodit_isamatch,
|
|
1968
1828
|
verify_rodit_islive,
|
|
1969
1829
|
verify_rodit_istrusted_issuingsmartcontract,
|
package/lib/auth/tokenservice.js
CHANGED
|
@@ -2085,7 +2085,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2085
2085
|
// Starting verification with more detailed logging
|
|
2086
2086
|
logger.debug("Starting verification checks", {
|
|
2087
2087
|
requestId,
|
|
2088
|
-
checks: ["match", "live", "
|
|
2088
|
+
checks: ["match", "live", "trusted"],
|
|
2089
2089
|
serviceProviderId: config_own_rodit.own_rodit.metadata.serviceprovider_id,
|
|
2090
2090
|
});
|
|
2091
2091
|
|
|
@@ -2093,7 +2093,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2093
2093
|
const {
|
|
2094
2094
|
verify_rodit_isamatch,
|
|
2095
2095
|
verify_rodit_islive,
|
|
2096
|
-
verify_rodit_isactive,
|
|
2097
2096
|
verify_rodit_istrusted_issuingsmartcontract
|
|
2098
2097
|
} = require("./authentication");
|
|
2099
2098
|
|
|
@@ -2188,49 +2187,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
|
|
|
2188
2187
|
};
|
|
2189
2188
|
}
|
|
2190
2189
|
|
|
2191
|
-
// Perform active verification
|
|
2192
|
-
const activeStart = performance.now();
|
|
2193
|
-
const isActive = await verify_rodit_isactive(
|
|
2194
|
-
peer_rodit.token_id,
|
|
2195
|
-
config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
|
|
2196
|
-
);
|
|
2197
|
-
const activeDuration = performance.now() - activeStart;
|
|
2198
|
-
|
|
2199
|
-
logger.debug("Active verification completed", {
|
|
2200
|
-
requestId,
|
|
2201
|
-
activeDuration,
|
|
2202
|
-
isActive,
|
|
2203
|
-
tokenId: peer_rodit.token_id,
|
|
2204
|
-
url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
|
|
2205
|
-
});
|
|
2206
|
-
|
|
2207
|
-
if (!isActive) {
|
|
2208
|
-
logger.warn("RODiT active verification failed", {
|
|
2209
|
-
component: "JwtAuth",
|
|
2210
|
-
method: "thorough_validate_jwt_token_be",
|
|
2211
|
-
requestId,
|
|
2212
|
-
duration: performance.now() - startTime,
|
|
2213
|
-
tokenId: peer_rodit.token_id,
|
|
2214
|
-
url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
// Add metrics for failed active verification
|
|
2218
|
-
logger.metric &&
|
|
2219
|
-
logger.metric("jwt_thorough_validation", performance.now() - startTime, {
|
|
2220
|
-
result: "active_failed",
|
|
2221
|
-
token_jti: token.jti || "unknown",
|
|
2222
|
-
peer_rodit_id: peer_rodit.token_id,
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
return {
|
|
2226
|
-
isValid: false,
|
|
2227
|
-
notAfter: null,
|
|
2228
|
-
error: "RODiT active verification failed",
|
|
2229
|
-
errorCode: "SERVER_RODIT_REVOKED",
|
|
2230
|
-
errorMessage: "Server's RODiT has been revoked"
|
|
2231
|
-
};
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
2190
|
// Perform trusted verification
|
|
2235
2191
|
const trustedStart = performance.now();
|
|
2236
2192
|
const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
|
|
@@ -586,9 +586,14 @@ async function login_client(req, res) {
|
|
|
586
586
|
// Set the jwt_token in the response header
|
|
587
587
|
res.setHeader('New-Token', jwt_token);
|
|
588
588
|
|
|
589
|
+
// Server-derived peer identity (verified RODiT only — do not trust client body).
|
|
590
|
+
const authenticatedRoditId = peer_rodit.token_id;
|
|
591
|
+
req.authenticatedRoditId = authenticatedRoditId;
|
|
592
|
+
|
|
589
593
|
return res.json({
|
|
590
594
|
jwt_token,
|
|
591
|
-
requestId
|
|
595
|
+
requestId,
|
|
596
|
+
roditid: authenticatedRoditId
|
|
592
597
|
});
|
|
593
598
|
} catch (error) {
|
|
594
599
|
const duration = Date.now() - startTime;
|
|
@@ -1453,13 +1458,19 @@ async function login_client(req, res) {
|
|
|
1453
1458
|
requestId,
|
|
1454
1459
|
response: {
|
|
1455
1460
|
requestId: requestId,
|
|
1456
|
-
jwt_token_length: jwt_token ? jwt_token.length : 0
|
|
1461
|
+
jwt_token_length: jwt_token ? jwt_token.length : 0,
|
|
1462
|
+
roditid: peer_rodit.token_id
|
|
1457
1463
|
}
|
|
1458
1464
|
});
|
|
1459
1465
|
|
|
1466
|
+
// Server-derived peer identity (verified RODiT only — do not trust client body).
|
|
1467
|
+
const authenticatedRoditId = peer_rodit.token_id;
|
|
1468
|
+
req.authenticatedRoditId = authenticatedRoditId;
|
|
1469
|
+
|
|
1460
1470
|
return res.json({
|
|
1461
1471
|
jwt_token,
|
|
1462
1472
|
requestId,
|
|
1473
|
+
roditid: authenticatedRoditId,
|
|
1463
1474
|
});
|
|
1464
1475
|
} catch (error) {
|
|
1465
1476
|
const duration = Date.now() - startTime;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforce per-route rate limits from `req.rateLimit` (set by validatepermissions).
|
|
3
|
+
* Copyright (c) 2026 Discernible IO. All rights reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { ulid } = require("ulid");
|
|
7
|
+
const logger = require("../../services/logger");
|
|
8
|
+
const { createLogContext } = logger;
|
|
9
|
+
const { sendError } = require("../../services/error-response");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory fixed-window counters keyed by subject + path + operation.
|
|
13
|
+
* Suitable for single-process apps; replace/store externally for multi-instance.
|
|
14
|
+
*/
|
|
15
|
+
const windows = new Map();
|
|
16
|
+
|
|
17
|
+
function cleanupExpired(now) {
|
|
18
|
+
if (windows.size < 1000) return;
|
|
19
|
+
for (const [key, entry] of windows) {
|
|
20
|
+
if (entry.resetAt <= now) {
|
|
21
|
+
windows.delete(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rateLimitSubject(req) {
|
|
27
|
+
if (req.authenticatedRoditId) return String(req.authenticatedRoditId);
|
|
28
|
+
if (req.user) {
|
|
29
|
+
if (req.user.sub) return String(req.user.sub);
|
|
30
|
+
if (req.user.rodit_id) return String(req.user.rodit_id);
|
|
31
|
+
}
|
|
32
|
+
return req.ip || "anonymous";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Middleware factory that consumes `req.rateLimit` metadata produced by
|
|
37
|
+
* `validatepermissions`. Place after permission validation on protected routes.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} [options]
|
|
40
|
+
* @param {Map} [options.store] Optional external store (Map-like get/set)
|
|
41
|
+
* @returns {Function} Express middleware
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* app.use('/api', authenticate_apicall, validatepermissions, enforceRateLimitFromClaims());
|
|
45
|
+
*/
|
|
46
|
+
function enforceRateLimitFromClaims(options = {}) {
|
|
47
|
+
const store = options.store || windows;
|
|
48
|
+
|
|
49
|
+
return function enforceRateLimitFromClaimsMiddleware(req, res, next) {
|
|
50
|
+
const requestId = req.requestId || ulid();
|
|
51
|
+
const rate = req.rateLimit;
|
|
52
|
+
|
|
53
|
+
if (!rate || rate.unlimited === true) {
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const limit = Number(rate.value);
|
|
58
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
59
|
+
// Missing / unparsable claim → do not invent a limit
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const timeWindowSeconds =
|
|
64
|
+
Number.isFinite(Number(rate.timeWindow)) && Number(rate.timeWindow) > 0
|
|
65
|
+
? Number(rate.timeWindow)
|
|
66
|
+
: 60;
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const subject = rateLimitSubject(req);
|
|
69
|
+
const pathKey = rate.path || req.path || "";
|
|
70
|
+
const operation = rate.operation || "";
|
|
71
|
+
const key = `${subject}|${pathKey}|${operation}`;
|
|
72
|
+
|
|
73
|
+
cleanupExpired(now);
|
|
74
|
+
|
|
75
|
+
let entry = store.get(key);
|
|
76
|
+
if (!entry || entry.resetAt <= now) {
|
|
77
|
+
entry = {
|
|
78
|
+
count: 0,
|
|
79
|
+
resetAt: now + timeWindowSeconds * 1000,
|
|
80
|
+
limit,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
entry.count += 1;
|
|
85
|
+
store.set(key, entry);
|
|
86
|
+
|
|
87
|
+
const remaining = Math.max(0, limit - entry.count);
|
|
88
|
+
const resetSeconds = Math.max(1, Math.ceil((entry.resetAt - now) / 1000));
|
|
89
|
+
|
|
90
|
+
res.setHeader("X-RateLimit-Limit", String(limit));
|
|
91
|
+
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
|
92
|
+
res.setHeader("X-RateLimit-Reset", String(resetSeconds));
|
|
93
|
+
|
|
94
|
+
if (entry.count > limit) {
|
|
95
|
+
const baseContext = createLogContext("EnforceRateLimit", "claimExceeded", {
|
|
96
|
+
requestId,
|
|
97
|
+
subject,
|
|
98
|
+
path: pathKey,
|
|
99
|
+
operation,
|
|
100
|
+
limit,
|
|
101
|
+
count: entry.count,
|
|
102
|
+
timeWindowSeconds,
|
|
103
|
+
});
|
|
104
|
+
logger.warnWithContext("Per-route claim rate limit exceeded", {
|
|
105
|
+
...baseContext,
|
|
106
|
+
result: "blocked",
|
|
107
|
+
});
|
|
108
|
+
logger.metric &&
|
|
109
|
+
logger.metric("rate_limit_operations", 0, {
|
|
110
|
+
operation: "claim_limit_exceeded",
|
|
111
|
+
path: pathKey,
|
|
112
|
+
result: "blocked",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return sendError(res, {
|
|
116
|
+
statusCode: 429,
|
|
117
|
+
requestId,
|
|
118
|
+
code: "RATE_LIMIT_EXCEEDED",
|
|
119
|
+
message: "Rate limit exceeded for this permissioned route",
|
|
120
|
+
details: {
|
|
121
|
+
limit,
|
|
122
|
+
timeWindowSeconds,
|
|
123
|
+
operation: operation || undefined,
|
|
124
|
+
path: pathKey || undefined,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = enforceRateLimitFromClaims;
|
|
134
|
+
module.exports.enforceRateLimitFromClaims = enforceRateLimitFromClaims;
|
|
135
|
+
module.exports._windows = windows;
|
|
@@ -416,7 +416,7 @@ async function validatepermissions(req, res, next) {
|
|
|
416
416
|
req.commentsRate = result.commentsRate;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
-
// Make rate limit information available for
|
|
419
|
+
// Make rate limit information available for enforceRateLimitFromClaims()
|
|
420
420
|
req.rateLimit = {
|
|
421
421
|
value: result.rateLimit,
|
|
422
422
|
unlimited: result.unlimited === true,
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
10
|
const https = require("https");
|
|
11
|
+
const dns = require("dns").promises;
|
|
12
|
+
const net = require("net");
|
|
11
13
|
const { Agent } = require("undici");
|
|
12
14
|
const config = require('../../services/configsdk');
|
|
13
15
|
const { isStrictEnvironment, getNodeEnv } = require('../../services/env');
|
|
@@ -20,6 +22,304 @@ const stateManager = require("../blockchain/statemanager");
|
|
|
20
22
|
const { sessionManager } = require("../auth/sessionmanager");
|
|
21
23
|
const { authenticate_webhook } = require("../auth/authentication");
|
|
22
24
|
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Outbound webhook URL SSRF / CIDR / DNS-pin helpers
|
|
27
|
+
// Policy aligned with SLC `src/game/url-join-identity.js`.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function webhookUrlError(code, message) {
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
err.code = code;
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reject loopback / RFC1918 / link-local / ULA / CGNAT / cloud-metadata destinations.
|
|
38
|
+
* Self-signed TLS for public webhook hosts remains allowed (WEBHOOK_TLS_SKIP_VERIFY).
|
|
39
|
+
*/
|
|
40
|
+
function isBlockedIpAddress(ip) {
|
|
41
|
+
const version = net.isIP(ip);
|
|
42
|
+
if (version === 4) {
|
|
43
|
+
const parts = ip.split(".").map((p) => Number(p));
|
|
44
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const [a, b] = parts;
|
|
48
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
49
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
50
|
+
if (a === 127) return true; // 127.0.0.0/8
|
|
51
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / metadata
|
|
52
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
53
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
54
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
55
|
+
if (a >= 224) return true; // multicast / reserved
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (version === 6) {
|
|
59
|
+
const normalized = ip.toLowerCase();
|
|
60
|
+
if (normalized === "::" || normalized === "::1") return true;
|
|
61
|
+
const mapped = normalized.match(/:ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
62
|
+
if (mapped) return isBlockedIpAddress(mapped[1]);
|
|
63
|
+
const first = normalized.split(":")[0] || "";
|
|
64
|
+
const firstVal = Number.parseInt(first, 16);
|
|
65
|
+
if (Number.isFinite(firstVal)) {
|
|
66
|
+
if ((firstVal & 0xffc0) === 0xfe80) return true; // fe80::/10
|
|
67
|
+
if ((firstVal & 0xfe00) === 0xfc00) return true; // fc00::/7
|
|
68
|
+
if ((firstVal & 0xff00) === 0xff00) return true; // ff00::/8
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isBlockedHostname(hostname) {
|
|
76
|
+
const host = String(hostname || "")
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/\.$/, "")
|
|
79
|
+
.replace(/^\[|\]$/g, "");
|
|
80
|
+
if (!host) return true;
|
|
81
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
82
|
+
if (host === "metadata.google.internal" || host.endsWith(".metadata.google.internal")) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (host === "metadata" || host.endsWith(".internal")) return true;
|
|
86
|
+
if (net.isIP(host)) return isBlockedIpAddress(host);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Synchronous URL policy checks (scheme, length, userinfo, blocked hostname literals).
|
|
92
|
+
* @param {string} raw Absolute http(s) URL
|
|
93
|
+
* @returns {URL}
|
|
94
|
+
*/
|
|
95
|
+
function validateOutboundWebhookUrl(raw) {
|
|
96
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
97
|
+
throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is required");
|
|
98
|
+
}
|
|
99
|
+
const trimmed = raw.trim();
|
|
100
|
+
if (trimmed.length > 2048) {
|
|
101
|
+
throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is too long");
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(trimmed);
|
|
106
|
+
} catch {
|
|
107
|
+
throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must be an absolute URL");
|
|
108
|
+
}
|
|
109
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
110
|
+
throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must use http or https");
|
|
111
|
+
}
|
|
112
|
+
if (parsed.username || parsed.password) {
|
|
113
|
+
throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must not contain userinfo");
|
|
114
|
+
}
|
|
115
|
+
if (isBlockedHostname(parsed.hostname)) {
|
|
116
|
+
throw webhookUrlError(
|
|
117
|
+
"WEBHOOK_URL_BLOCKED",
|
|
118
|
+
"Webhook URL must not target a private, loopback, or metadata host"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ipv4ToInt(ip) {
|
|
125
|
+
return ip.split(".").reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0) >>> 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseCidrList(cidrClaim) {
|
|
129
|
+
if (cidrClaim == null) return [];
|
|
130
|
+
const raw = String(cidrClaim).trim();
|
|
131
|
+
if (!raw) return [];
|
|
132
|
+
return raw
|
|
133
|
+
.split(/[\s,;]+/)
|
|
134
|
+
.map((part) => part.trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isUnrestrictedCidr(cidr) {
|
|
139
|
+
const value = String(cidr || "").trim().toLowerCase();
|
|
140
|
+
return value === "0.0.0.0/0" || value === "::/0" || value === "*";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isIpInCidr(ip, cidr) {
|
|
144
|
+
const trimmed = String(cidr || "").trim();
|
|
145
|
+
if (!trimmed || isUnrestrictedCidr(trimmed)) return true;
|
|
146
|
+
|
|
147
|
+
const slash = trimmed.indexOf("/");
|
|
148
|
+
if (slash < 0) return false;
|
|
149
|
+
const base = trimmed.slice(0, slash);
|
|
150
|
+
const prefix = Number(trimmed.slice(slash + 1));
|
|
151
|
+
const ipVer = net.isIP(ip);
|
|
152
|
+
const baseVer = net.isIP(base);
|
|
153
|
+
if (!ipVer || !baseVer || ipVer !== baseVer || !Number.isInteger(prefix)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (ipVer === 4) {
|
|
158
|
+
if (prefix < 0 || prefix > 32) return false;
|
|
159
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
160
|
+
return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// IPv6: compare prefix bits of normalized 128-bit forms
|
|
164
|
+
if (prefix < 0 || prefix > 128) return false;
|
|
165
|
+
const toBytes = (addr) => {
|
|
166
|
+
const buf = Buffer.alloc(16);
|
|
167
|
+
const full = expandIpv6(addr);
|
|
168
|
+
for (let i = 0; i < 8; i++) {
|
|
169
|
+
const hextet = Number.parseInt(full[i], 16);
|
|
170
|
+
buf.writeUInt16BE(hextet, i * 2);
|
|
171
|
+
}
|
|
172
|
+
return buf;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const ipBuf = toBytes(ip);
|
|
177
|
+
const baseBuf = toBytes(base);
|
|
178
|
+
const fullBytes = Math.floor(prefix / 8);
|
|
179
|
+
const remBits = prefix % 8;
|
|
180
|
+
for (let i = 0; i < fullBytes; i++) {
|
|
181
|
+
if (ipBuf[i] !== baseBuf[i]) return false;
|
|
182
|
+
}
|
|
183
|
+
if (remBits === 0) return true;
|
|
184
|
+
const mask = 0xff << (8 - remBits);
|
|
185
|
+
return (ipBuf[fullBytes] & mask) === (baseBuf[fullBytes] & mask);
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function expandIpv6(addr) {
|
|
192
|
+
const lower = String(addr).toLowerCase().replace(/^\[|\]$/g, "");
|
|
193
|
+
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
194
|
+
if (mapped) {
|
|
195
|
+
const parts = mapped[1].split(".").map(Number);
|
|
196
|
+
const hi = ((parts[0] << 8) | parts[1]).toString(16);
|
|
197
|
+
const lo = ((parts[2] << 8) | parts[3]).toString(16);
|
|
198
|
+
return ["0", "0", "0", "0", "0", "ffff", hi, lo];
|
|
199
|
+
}
|
|
200
|
+
const [head, tail] = lower.split("::");
|
|
201
|
+
const headParts = head ? head.split(":") : [];
|
|
202
|
+
const tailParts = tail ? tail.split(":") : [];
|
|
203
|
+
const missing = 8 - (headParts.length + tailParts.length);
|
|
204
|
+
if (missing < 0) {
|
|
205
|
+
throw new Error("invalid ipv6");
|
|
206
|
+
}
|
|
207
|
+
const middle = Array(missing).fill("0");
|
|
208
|
+
const parts = [...headParts, ...middle, ...tailParts].map((p) => p || "0");
|
|
209
|
+
if (parts.length !== 8) {
|
|
210
|
+
throw new Error("invalid ipv6");
|
|
211
|
+
}
|
|
212
|
+
return parts;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertIpAllowedByWebhookCidr(ip, cidrClaim) {
|
|
216
|
+
const cidrs = parseCidrList(cidrClaim);
|
|
217
|
+
if (cidrs.length === 0 || cidrs.every(isUnrestrictedCidr)) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const allowed = cidrs.some((cidr) => isIpInCidr(ip, cidr));
|
|
221
|
+
if (!allowed) {
|
|
222
|
+
throw webhookUrlError(
|
|
223
|
+
"WEBHOOK_CIDR_DENIED",
|
|
224
|
+
"Resolved webhook address is outside the peer webhook_cidr allowlist"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve hostname once, reject blocked addresses, enforce webhook_cidr, and
|
|
231
|
+
* return a single pinned address for the request lifetime (DNS rebinding defense).
|
|
232
|
+
*
|
|
233
|
+
* @param {string|URL} url
|
|
234
|
+
* @param {string} [webhookCidr] JWT/peer `rodit_webhookcidr` / `webhook_cidr`
|
|
235
|
+
* @returns {Promise<{ url: URL, hostname: string, address: string, family: number }>}
|
|
236
|
+
*/
|
|
237
|
+
async function resolveAndPinWebhookDestination(url, webhookCidr) {
|
|
238
|
+
const parsed = url instanceof URL ? url : validateOutboundWebhookUrl(url);
|
|
239
|
+
const hostname = String(parsed.hostname || "").replace(/^\[|\]$/g, "");
|
|
240
|
+
|
|
241
|
+
if (net.isIP(hostname)) {
|
|
242
|
+
if (isBlockedIpAddress(hostname)) {
|
|
243
|
+
throw webhookUrlError(
|
|
244
|
+
"WEBHOOK_URL_BLOCKED",
|
|
245
|
+
"Webhook URL must not target a private, loopback, or metadata host"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
assertIpAllowedByWebhookCidr(hostname, webhookCidr);
|
|
249
|
+
return {
|
|
250
|
+
url: parsed,
|
|
251
|
+
hostname,
|
|
252
|
+
address: hostname,
|
|
253
|
+
family: net.isIP(hostname),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let records;
|
|
258
|
+
try {
|
|
259
|
+
records = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
260
|
+
} catch {
|
|
261
|
+
throw webhookUrlError(
|
|
262
|
+
"WEBHOOK_URL_UNRESOLVABLE",
|
|
263
|
+
"Webhook URL hostname could not be resolved"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (!records.length) {
|
|
267
|
+
throw webhookUrlError(
|
|
268
|
+
"WEBHOOK_URL_UNRESOLVABLE",
|
|
269
|
+
"Webhook URL hostname could not be resolved"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const candidates = records.filter((record) => !isBlockedIpAddress(record.address));
|
|
274
|
+
if (!candidates.length) {
|
|
275
|
+
throw webhookUrlError(
|
|
276
|
+
"WEBHOOK_URL_BLOCKED",
|
|
277
|
+
"Webhook URL must not resolve to a private, loopback, or metadata address"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const cidrs = parseCidrList(webhookCidr);
|
|
282
|
+
const restrictCidr = cidrs.length > 0 && !cidrs.every(isUnrestrictedCidr);
|
|
283
|
+
const allowed = restrictCidr
|
|
284
|
+
? candidates.filter((record) => cidrs.some((cidr) => isIpInCidr(record.address, cidr)))
|
|
285
|
+
: candidates;
|
|
286
|
+
|
|
287
|
+
if (!allowed.length) {
|
|
288
|
+
throw webhookUrlError(
|
|
289
|
+
"WEBHOOK_CIDR_DENIED",
|
|
290
|
+
"Resolved webhook address is outside the peer webhook_cidr allowlist"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Prefer IPv4 when available for broader destination compatibility.
|
|
295
|
+
const pinned = allowed.find((r) => r.family === 4) || allowed[0];
|
|
296
|
+
return {
|
|
297
|
+
url: parsed,
|
|
298
|
+
hostname,
|
|
299
|
+
address: pinned.address,
|
|
300
|
+
family: pinned.family,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* undici Agent that always returns the pinned address for DNS lookups
|
|
306
|
+
* (keeps Host / SNI as the original hostname).
|
|
307
|
+
*/
|
|
308
|
+
function createPinnedDispatcher({ address, family, skipTlsVerify = false }) {
|
|
309
|
+
return new Agent({
|
|
310
|
+
connect: {
|
|
311
|
+
rejectUnauthorized: !skipTlsVerify,
|
|
312
|
+
lookup: (hostname, options, callback) => {
|
|
313
|
+
if (options && options.all) {
|
|
314
|
+
callback(null, [{ address, family }]);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
callback(null, address, family);
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
23
323
|
// ---------------------------------------------------------------------------
|
|
24
324
|
// Webhook identity + session helpers
|
|
25
325
|
// ---------------------------------------------------------------------------
|
|
@@ -915,12 +1215,52 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
|
|
|
915
1215
|
.replace(/\/+$/, "");
|
|
916
1216
|
const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
|
|
917
1217
|
const formattedWebhookUrl = `https://${cleanWebhookUrl}${normalizedEndpoint}`;
|
|
1218
|
+
|
|
1219
|
+
// Outbound SSRF controls: reject userinfo / private / metadata destinations,
|
|
1220
|
+
// enforce peer webhook_cidr, and pin DNS for the request lifetime.
|
|
1221
|
+
// WEBHOOK_TLS_SKIP_VERIFY remains allowed for public self-signed hosts.
|
|
1222
|
+
let pinnedDestination;
|
|
1223
|
+
try {
|
|
1224
|
+
validateOutboundWebhookUrl(formattedWebhookUrl);
|
|
1225
|
+
pinnedDestination = await resolveAndPinWebhookDestination(
|
|
1226
|
+
formattedWebhookUrl,
|
|
1227
|
+
req.user.rodit_webhookcidr
|
|
1228
|
+
);
|
|
1229
|
+
} catch (ssrfError) {
|
|
1230
|
+
const duration = Date.now() - startTime;
|
|
1231
|
+
const errorCode = ssrfError.code || "WEBHOOK_URL_BLOCKED";
|
|
1232
|
+
logger.warnWithContext("Outbound webhook URL rejected by SSRF controls", {
|
|
1233
|
+
...baseContext,
|
|
1234
|
+
duration,
|
|
1235
|
+
formattedWebhookUrl,
|
|
1236
|
+
webhookCidr: req.user.rodit_webhookcidr || null,
|
|
1237
|
+
errorCode,
|
|
1238
|
+
error: ssrfError.message
|
|
1239
|
+
});
|
|
1240
|
+
logger.metric &&
|
|
1241
|
+
logger.metric("webhook_delivery_failures_total", 1, {
|
|
1242
|
+
component: "WebhookHandler",
|
|
1243
|
+
reason: errorCode,
|
|
1244
|
+
event,
|
|
1245
|
+
});
|
|
1246
|
+
return {
|
|
1247
|
+
isValid: false,
|
|
1248
|
+
error: {
|
|
1249
|
+
code: errorCode,
|
|
1250
|
+
message: ssrfError.message,
|
|
1251
|
+
requestId,
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
918
1255
|
|
|
919
1256
|
logger.debugWithContext("Webhook URL details", {
|
|
920
1257
|
...baseContext,
|
|
921
1258
|
rawWebhookUrl: webhookUrl,
|
|
922
1259
|
endpoint,
|
|
923
|
-
formattedWebhookUrl
|
|
1260
|
+
formattedWebhookUrl,
|
|
1261
|
+
pinnedAddress: pinnedDestination.address,
|
|
1262
|
+
pinnedFamily: pinnedDestination.family,
|
|
1263
|
+
webhookCidr: req.user.rodit_webhookcidr || null
|
|
924
1264
|
});
|
|
925
1265
|
|
|
926
1266
|
const timestamp = Date.now();
|
|
@@ -1186,7 +1526,8 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
|
|
|
1186
1526
|
// Configure HTTPS agent to skip TLS verification if configured
|
|
1187
1527
|
// This is necessary when webhook destinations use self-signed certificates
|
|
1188
1528
|
// Since mutual authentication via digital signatures is already in place,
|
|
1189
|
-
// skipping TLS verification is safe in this context
|
|
1529
|
+
// skipping TLS verification is safe in this context (public destinations only;
|
|
1530
|
+
// private/metadata hosts are rejected above).
|
|
1190
1531
|
const skipTlsVerify = config.has('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')
|
|
1191
1532
|
? String(config.get('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')).toLowerCase() === 'true'
|
|
1192
1533
|
: false;
|
|
@@ -1196,27 +1537,37 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
|
|
|
1196
1537
|
headers: headers,
|
|
1197
1538
|
body: payload,
|
|
1198
1539
|
};
|
|
1199
|
-
|
|
1540
|
+
|
|
1541
|
+
// Always pin DNS via undici lookup so a rebinding race cannot retarget
|
|
1542
|
+
// the TCP connection after the SSRF checks above.
|
|
1543
|
+
const undiciAgent = createPinnedDispatcher({
|
|
1544
|
+
address: pinnedDestination.address,
|
|
1545
|
+
family: pinnedDestination.family,
|
|
1546
|
+
skipTlsVerify,
|
|
1547
|
+
});
|
|
1548
|
+
fetchOptions.dispatcher = undiciAgent;
|
|
1549
|
+
|
|
1200
1550
|
if (skipTlsVerify) {
|
|
1201
|
-
// Create custom undici Agent that accepts self-signed certificates
|
|
1202
|
-
// Node.js fetch uses undici under the hood and requires 'dispatcher' option
|
|
1203
|
-
const undiciAgent = new Agent({
|
|
1204
|
-
connect: {
|
|
1205
|
-
rejectUnauthorized: false
|
|
1206
|
-
}
|
|
1207
|
-
});
|
|
1208
|
-
fetchOptions.dispatcher = undiciAgent;
|
|
1209
|
-
|
|
1210
1551
|
logger.debugWithContext("Webhook TLS verification disabled", {
|
|
1211
1552
|
...baseContext,
|
|
1212
1553
|
skipTlsVerify: true,
|
|
1213
|
-
reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true"
|
|
1554
|
+
reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true",
|
|
1555
|
+
pinnedAddress: pinnedDestination.address
|
|
1214
1556
|
});
|
|
1215
1557
|
}
|
|
1216
1558
|
|
|
1217
1559
|
// Send webhook request
|
|
1218
1560
|
const fetchStartTime = Date.now();
|
|
1219
|
-
|
|
1561
|
+
let response;
|
|
1562
|
+
try {
|
|
1563
|
+
response = await fetch(formattedWebhookUrl, fetchOptions);
|
|
1564
|
+
} finally {
|
|
1565
|
+
try {
|
|
1566
|
+
await undiciAgent.close();
|
|
1567
|
+
} catch (_closeError) {
|
|
1568
|
+
// ignore dispatcher close errors
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1220
1571
|
const fetchDuration = Date.now() - fetchStartTime;
|
|
1221
1572
|
|
|
1222
1573
|
// Log fetch duration metrics
|
|
@@ -1748,6 +2099,11 @@ module.exports = {
|
|
|
1748
2099
|
implicitAccountToBase64urlKey,
|
|
1749
2100
|
base64urlKeyToImplicitAccount,
|
|
1750
2101
|
|
|
2102
|
+
// Outbound SSRF / CIDR / DNS-pin helpers
|
|
2103
|
+
validateOutboundWebhookUrl,
|
|
2104
|
+
resolveAndPinWebhookDestination,
|
|
2105
|
+
createPinnedDispatcher,
|
|
2106
|
+
|
|
1751
2107
|
// Added exports from eventhandler.js
|
|
1752
2108
|
WebhookEventHandler,
|
|
1753
2109
|
TestConfigUpdateHandler,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rodit/rodit-auth-be",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.16.0",
|
|
4
4
|
"description": "RODiT-based authentication system for Express.js applications",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -46,8 +46,13 @@
|
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
|
49
|
-
"url": "git+https://github.com/discernible-io/rodit-
|
|
49
|
+
"url": "git+https://github.com/discernible-io/rodit-sdk.git",
|
|
50
|
+
"directory": "rodit-auth-be"
|
|
50
51
|
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/discernible-io/rodit-sdk/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/discernible-io/rodit-sdk/tree/main/rodit-auth-be#readme",
|
|
51
56
|
"publishConfig": {
|
|
52
57
|
"access": "public"
|
|
53
58
|
},
|