@getstrata/core 1.0.9 → 1.1.1
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 +51 -0
- package/dist/core/auth/authContext.d.ts +5 -2
- package/dist/core/auth/emailVerification.d.ts +1 -1
- package/dist/core/auth/guard.d.ts +5 -1
- package/dist/core/auth/jwtGuard.d.ts +6 -2
- package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
- package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
- package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
- package/dist/core/auth/oneTimeToken.d.ts +19 -0
- package/dist/core/auth/passwordLogin.d.ts +20 -0
- package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
- package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
- package/dist/core/contracts/authUserDirectory.d.ts +2 -3
- package/dist/core/database/errors.d.ts +2 -1
- package/dist/core/http/response.d.ts +3 -1
- package/dist/core/mail/mailer.d.ts +6 -1
- package/dist/core/security/oauthState.d.ts +6 -2
- package/dist/core/security/safeFetch.d.ts +1 -0
- package/dist/core/security/safePath.d.ts +4 -0
- package/dist/core/security/safeUrl.d.ts +14 -1
- package/dist/core/security/totp.d.ts +1 -1
- package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
- package/dist/core/tenant/enableTenantRls.d.ts +6 -0
- package/dist/core/tenant/postgresAppRole.d.ts +75 -0
- package/dist/entries/audit/exportAuditLogs.js +223 -40
- package/dist/entries/auth/accessControl.js +1 -0
- package/dist/entries/auth/basicAuthGuard.js +638 -17
- package/dist/entries/auth/emailVerification.js +1 -1
- package/dist/entries/auth/intendedUrlCookie.js +32 -2
- package/dist/entries/auth/jwt.js +1 -1
- package/dist/entries/auth/jwtGuard.js +192 -3
- package/dist/entries/auth/membershipMiddleware.js +1 -0
- package/dist/entries/auth/membershipScope.js +1 -0
- package/dist/entries/auth/membershipService.js +1 -0
- package/dist/entries/auth/oauth/oidcProvider.js +542 -20
- package/dist/entries/auth/oauth/providers.js +313 -5
- package/dist/entries/auth/oauth/samlProvider.js +552 -74
- package/dist/entries/auth/oneTimeToken.js +362 -0
- package/dist/entries/auth/password.js +1 -1
- package/dist/entries/auth/passwordLogin.js +548 -0
- package/dist/entries/auth/policy.js +1 -0
- package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
- package/dist/entries/auth/scimAuthMiddleware.js +10 -3
- package/dist/entries/auth/sessionCookie.js +1 -1
- package/dist/entries/auth/sessionGuard.js +1 -1
- package/dist/entries/auth/tokenHash.js +2 -6
- package/dist/entries/crypto/fieldEncryption.js +1 -1
- package/dist/entries/crypto/mfaSecret.js +31 -5
- package/dist/entries/database/errors.js +29 -12
- package/dist/entries/database/model.js +9 -9
- package/dist/entries/facades.js +72 -9
- package/dist/entries/http/authMiddleware.js +7 -14
- package/dist/entries/http/corsMiddleware.js +69 -2
- package/dist/entries/http/csrfMiddleware.js +33 -43
- package/dist/entries/http/csrfToken.js +2 -2
- package/dist/entries/http/flashMiddleware.js +4 -2
- package/dist/entries/http/flashSession.js +4 -2
- package/dist/entries/http/formRequest.js +2 -2
- package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
- package/dist/entries/http/pagination.js +2 -2
- package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
- package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
- package/dist/entries/http/response.js +101 -45
- package/dist/entries/http/routeModelBinding.js +2 -2
- package/dist/entries/http/safeInternalPath.js +32 -2
- package/dist/entries/http/scimThrottleMiddleware.js +7 -1
- package/dist/entries/http/securedRouteModelBinding.js +2 -2
- package/dist/entries/http/securityHeadersMiddleware.js +3 -64
- package/dist/entries/http/signedUrl.js +6 -5
- package/dist/entries/http/throttleMiddleware.js +63 -0
- package/dist/entries/http/validation.js +2 -2
- package/dist/entries/http/webFormRequest.js +2 -2
- package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
- package/dist/entries/mail/mailer.js +54 -8
- package/dist/entries/media/imageTransform.js +12 -1
- package/dist/entries/openapi/generator.js +0 -7
- package/dist/entries/security/oauthState.js +30 -39
- package/dist/entries/security/publicReads.js +1 -1
- package/dist/entries/security/recoveryCodes.js +2 -2
- package/dist/entries/security/safeFetch.js +195 -94
- package/dist/entries/security/safePath.js +25 -0
- package/dist/entries/security/scimTenantTokens.js +10 -3
- package/dist/entries/security/totp.js +16 -4
- package/dist/entries/storage/storage.js +24 -2
- package/dist/entries/tenant/databaseTenantContext.js +34 -12
- package/dist/entries/tenant/enableTenantRls.js +385 -0
- package/dist/entries/tracing/tracingMiddleware.js +308 -5
- package/dist/framework/public-api.d.ts +10 -3
- package/dist/index.js +2767 -1515
- package/package.json +32 -3
|
@@ -4,7 +4,7 @@ import { randomBytes } from "crypto";
|
|
|
4
4
|
import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
5
5
|
import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
|
|
6
6
|
var DEFAULT_RECOVERY_CODE_COUNT = 8;
|
|
7
|
-
var RECOVERY_CODE_BYTES =
|
|
7
|
+
var RECOVERY_CODE_BYTES = 16;
|
|
8
8
|
function normalizeRecoveryCode(code) {
|
|
9
9
|
return code.replace(/[^a-z0-9]/giu, "").toLowerCase();
|
|
10
10
|
}
|
|
@@ -12,7 +12,7 @@ function formatRecoveryCode(normalized) {
|
|
|
12
12
|
if (normalized.length !== RECOVERY_CODE_BYTES * 2) {
|
|
13
13
|
return normalized;
|
|
14
14
|
}
|
|
15
|
-
return
|
|
15
|
+
return normalized.match(/.{1,4}/gu)?.join("-") ?? normalized;
|
|
16
16
|
}
|
|
17
17
|
function generateRecoveryCodes(count = DEFAULT_RECOVERY_CODE_COUNT) {
|
|
18
18
|
const size = Number.isInteger(count) && count > 0 ? count : DEFAULT_RECOVERY_CODE_COUNT;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// ../../src/core/security/safeUrl.ts
|
|
3
|
+
import { lookup as dnsLookupImpl } from "dns/promises";
|
|
4
|
+
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
5
|
+
|
|
2
6
|
// ../../src/core/runtime/appEnv.ts
|
|
3
7
|
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
4
8
|
function normalizeEnvValue(value) {
|
|
@@ -19,72 +23,7 @@ function envFlagEnabled(value) {
|
|
|
19
23
|
return value === "true";
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
// ../../src/core/runtime/appKeyPrefix.ts
|
|
23
|
-
function appKeyPrefix() {
|
|
24
|
-
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
25
|
-
}
|
|
26
|
-
function appCookieName(kind) {
|
|
27
|
-
return `${appKeyPrefix()}_${kind}`;
|
|
28
|
-
}
|
|
29
|
-
function appDevSecret(kind) {
|
|
30
|
-
return `${appKeyPrefix()}-dev-${kind}`;
|
|
31
|
-
}
|
|
32
|
-
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
33
|
-
for (const name of names) {
|
|
34
|
-
const value = env[name]?.trim();
|
|
35
|
-
if (value) {
|
|
36
|
-
return value;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (isProductionEnv(env)) {
|
|
40
|
-
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
41
|
-
}
|
|
42
|
-
return appDevSecret(devKind);
|
|
43
|
-
}
|
|
44
|
-
function namespacedRedisKey(kind) {
|
|
45
|
-
return `${appKeyPrefix()}:${kind}`;
|
|
46
|
-
}
|
|
47
|
-
function smtpEhloHost() {
|
|
48
|
-
const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
|
|
49
|
-
const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
|
|
50
|
-
return safe || "strata.local";
|
|
51
|
-
}
|
|
52
|
-
function siemEventType() {
|
|
53
|
-
return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
|
|
54
|
-
}
|
|
55
|
-
function appUserAgent() {
|
|
56
|
-
return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
|
|
57
|
-
}
|
|
58
|
-
function otelServiceName() {
|
|
59
|
-
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
60
|
-
}
|
|
61
|
-
function appDisplayName() {
|
|
62
|
-
return process.env.APP_NAME?.trim() || "Strata";
|
|
63
|
-
}
|
|
64
|
-
function appEnv() {
|
|
65
|
-
return process.env.APP_ENV?.trim() || "local";
|
|
66
|
-
}
|
|
67
|
-
function appUrl() {
|
|
68
|
-
return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
|
|
69
|
-
}
|
|
70
|
-
function apiPrefix() {
|
|
71
|
-
const raw = process.env.API_PREFIX?.trim() || "/api/v1";
|
|
72
|
-
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
73
|
-
const trimmed = withSlash.replace(/\/+$/, "");
|
|
74
|
-
return trimmed || "/api/v1";
|
|
75
|
-
}
|
|
76
|
-
function sdkClientClassName() {
|
|
77
|
-
const override = process.env.APP_SDK_CLASS?.trim();
|
|
78
|
-
if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
|
|
79
|
-
return override;
|
|
80
|
-
}
|
|
81
|
-
const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
|
|
82
|
-
return fromName ? `${fromName}Client` : "AppClient";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
26
|
// ../../src/core/security/safeUrl.ts
|
|
86
|
-
import { lookup as dnsLookupImpl } from "dns/promises";
|
|
87
|
-
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
88
27
|
var dnsLookup = dnsLookupImpl;
|
|
89
28
|
var BLOCKED_HOSTNAMES = new Set([
|
|
90
29
|
"localhost",
|
|
@@ -93,34 +32,144 @@ var BLOCKED_HOSTNAMES = new Set([
|
|
|
93
32
|
"::1",
|
|
94
33
|
"metadata.google.internal"
|
|
95
34
|
]);
|
|
96
|
-
function
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
return
|
|
35
|
+
function parseCanonicalDottedDecimal(hostname) {
|
|
36
|
+
const parts = hostname.split(".");
|
|
37
|
+
if (parts.length !== 4) {
|
|
38
|
+
return null;
|
|
100
39
|
}
|
|
101
|
-
const octets =
|
|
102
|
-
|
|
103
|
-
|
|
40
|
+
const octets = [];
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
if (!/^(0|[1-9]\d{0,2})$/u.test(part)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const value = Number.parseInt(part, 10);
|
|
46
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
octets.push(value);
|
|
104
50
|
}
|
|
51
|
+
return octets;
|
|
52
|
+
}
|
|
53
|
+
function parseDottedIpv4(hostname) {
|
|
54
|
+
return parseCanonicalDottedDecimal(hostname);
|
|
55
|
+
}
|
|
56
|
+
function isBlockedIpv4Octets(octets) {
|
|
105
57
|
const [a = 0, b = 0] = octets;
|
|
106
|
-
if (a === 10) {
|
|
58
|
+
if (a === 10 || a === 127 || a === 0) {
|
|
107
59
|
return true;
|
|
108
60
|
}
|
|
109
|
-
if (a ===
|
|
61
|
+
if (a === 169 && b === 254) {
|
|
110
62
|
return true;
|
|
111
63
|
}
|
|
112
|
-
if (a ===
|
|
64
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
113
65
|
return true;
|
|
114
66
|
}
|
|
115
|
-
if (a ===
|
|
67
|
+
if (a === 192 && b === 168) {
|
|
116
68
|
return true;
|
|
117
69
|
}
|
|
118
|
-
if (a ===
|
|
70
|
+
if (a === 100 && b >= 64 && b <= 127) {
|
|
119
71
|
return true;
|
|
120
72
|
}
|
|
121
|
-
if (a ===
|
|
73
|
+
if (a === 198 && (b === 18 || b === 19)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function expandIpv6(hostname) {
|
|
79
|
+
const trimmed = hostname.trim().toLowerCase();
|
|
80
|
+
const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
|
81
|
+
if (unbracketed.includes("%")) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const mapped = unbracketed.match(/^::ffff:([0-9a-fx.]+)$/iu);
|
|
85
|
+
if (mapped?.[1]) {
|
|
86
|
+
const octets = parseDottedIpv4(mapped[1]);
|
|
87
|
+
if (!octets) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
0,
|
|
92
|
+
0,
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
0,
|
|
96
|
+
65535,
|
|
97
|
+
octets[0] << 8 | octets[1],
|
|
98
|
+
octets[2] << 8 | octets[3]
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
const halves = unbracketed.split("::");
|
|
102
|
+
if (halves.length > 2) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const parseGroup = (part) => {
|
|
106
|
+
if (!/^[0-9a-f]{1,4}$/u.test(part)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return Number.parseInt(part, 16);
|
|
110
|
+
};
|
|
111
|
+
if (halves.length === 1) {
|
|
112
|
+
const groups = unbracketed.split(":");
|
|
113
|
+
if (groups.length !== 8) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const values = groups.map(parseGroup);
|
|
117
|
+
return values.every((value) => value !== null) ? values : null;
|
|
118
|
+
}
|
|
119
|
+
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
|
120
|
+
const tail = halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
|
121
|
+
if (head.length + tail.length > 8) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const values = [];
|
|
125
|
+
for (const part of head) {
|
|
126
|
+
const value = parseGroup(part);
|
|
127
|
+
if (value === null) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
values.push(value);
|
|
131
|
+
}
|
|
132
|
+
while (values.length < 8 - tail.length) {
|
|
133
|
+
values.push(0);
|
|
134
|
+
}
|
|
135
|
+
for (const part of tail) {
|
|
136
|
+
const value = parseGroup(part);
|
|
137
|
+
if (value === null) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
values.push(value);
|
|
141
|
+
}
|
|
142
|
+
return values.length === 8 ? values : null;
|
|
143
|
+
}
|
|
144
|
+
function isBlockedIpv6(hostname) {
|
|
145
|
+
const groups = expandIpv6(hostname);
|
|
146
|
+
if (!groups) {
|
|
147
|
+
return hostname.includes(":");
|
|
148
|
+
}
|
|
149
|
+
const first = groups[0] ?? 0;
|
|
150
|
+
const second = groups[1] ?? 0;
|
|
151
|
+
if (groups.every((group) => group === 0)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if ((first & 65472) === 65152) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if ((first & 65024) === 64512) {
|
|
122
161
|
return true;
|
|
123
162
|
}
|
|
163
|
+
if ((first & 65280) === 65280) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (first === 0 && second === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) {
|
|
167
|
+
const a = (groups[6] ?? 0) >> 8;
|
|
168
|
+
const b = (groups[6] ?? 0) & 255;
|
|
169
|
+
const c = (groups[7] ?? 0) >> 8;
|
|
170
|
+
const d = (groups[7] ?? 0) & 255;
|
|
171
|
+
return isBlockedIpv4Octets([a, b, c, d]);
|
|
172
|
+
}
|
|
124
173
|
return false;
|
|
125
174
|
}
|
|
126
175
|
function isBlockedHostname(hostname) {
|
|
@@ -135,24 +184,43 @@ function isBlockedHostname(hostname) {
|
|
|
135
184
|
return true;
|
|
136
185
|
}
|
|
137
186
|
if (normalized.includes(":")) {
|
|
187
|
+
return isBlockedIpv6(normalized);
|
|
188
|
+
}
|
|
189
|
+
if (/^\d+$/u.test(normalized)) {
|
|
138
190
|
return true;
|
|
139
191
|
}
|
|
140
|
-
|
|
192
|
+
if (normalized.includes(".")) {
|
|
193
|
+
const canonical = parseCanonicalDottedDecimal(normalized);
|
|
194
|
+
if (canonical) {
|
|
195
|
+
return isBlockedIpv4Octets(canonical);
|
|
196
|
+
}
|
|
197
|
+
if (/^[0-9a-fx.]+$/iu.test(normalized)) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
function productionSafeOutboundOptions(options) {
|
|
204
|
+
if (!isProductionEnv()) {
|
|
205
|
+
return options;
|
|
206
|
+
}
|
|
207
|
+
return { ...options, allowPrivate: false, resolveDns: true };
|
|
141
208
|
}
|
|
142
209
|
function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
210
|
+
const resolved = productionSafeOutboundOptions(options);
|
|
143
211
|
let parsed;
|
|
144
212
|
try {
|
|
145
213
|
parsed = new URL(rawUrl);
|
|
146
214
|
} catch {
|
|
147
215
|
throw new BadRequestError("Webhook URL is invalid.");
|
|
148
216
|
}
|
|
149
|
-
if (parsed.protocol !== "https:" && !(
|
|
217
|
+
if (parsed.protocol !== "https:" && !(resolved.allowHttp && parsed.protocol === "http:")) {
|
|
150
218
|
throw new BadRequestError("Webhook URL must use HTTPS.");
|
|
151
219
|
}
|
|
152
220
|
if (parsed.username || parsed.password) {
|
|
153
221
|
throw new BadRequestError("Webhook URL must not include credentials.");
|
|
154
222
|
}
|
|
155
|
-
if (isBlockedHostname(parsed.hostname)) {
|
|
223
|
+
if (!resolved.allowPrivate && isBlockedHostname(parsed.hostname)) {
|
|
156
224
|
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
157
225
|
}
|
|
158
226
|
return parsed;
|
|
@@ -160,17 +228,35 @@ function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
160
228
|
function isBlockedIpAddress(address) {
|
|
161
229
|
return isBlockedHostname(address.trim().toLowerCase());
|
|
162
230
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
231
|
+
function isLiteralIpHostname(hostname) {
|
|
232
|
+
return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
|
|
233
|
+
}
|
|
234
|
+
function pinUrlToAddress(url, address) {
|
|
235
|
+
const pinned = new URL(url.toString());
|
|
236
|
+
const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
|
|
237
|
+
pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
|
|
238
|
+
return pinned;
|
|
239
|
+
}
|
|
240
|
+
async function resolveSafeOutboundTarget(rawUrl, options = {}) {
|
|
241
|
+
const resolvedOptions = productionSafeOutboundOptions(options);
|
|
242
|
+
const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
|
|
243
|
+
if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
|
|
244
|
+
return { url: parsed, addresses: [] };
|
|
167
245
|
}
|
|
168
246
|
const hostname = parsed.hostname.trim().toLowerCase();
|
|
169
|
-
|
|
170
|
-
|
|
247
|
+
if (isLiteralIpHostname(hostname)) {
|
|
248
|
+
return { url: parsed, addresses: [hostname] };
|
|
249
|
+
}
|
|
250
|
+
let results;
|
|
251
|
+
try {
|
|
252
|
+
results = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
253
|
+
} catch {
|
|
171
254
|
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
172
255
|
}
|
|
173
|
-
|
|
256
|
+
if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
|
|
257
|
+
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
258
|
+
}
|
|
259
|
+
return { url: parsed, addresses: results.map((result) => result.address) };
|
|
174
260
|
}
|
|
175
261
|
|
|
176
262
|
// ../../src/core/security/safeFetch.ts
|
|
@@ -178,25 +264,40 @@ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
|
178
264
|
async function safeFetch(input, init = {}, options = {}) {
|
|
179
265
|
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
180
266
|
const maxRedirects = options.maxRedirects ?? 0;
|
|
181
|
-
const resolveDns = options.resolveDns ??
|
|
182
|
-
const urlOptions = {
|
|
267
|
+
const resolveDns = options.resolveDns ?? true;
|
|
268
|
+
const urlOptions = {
|
|
269
|
+
allowHttp: options.allowHttp,
|
|
270
|
+
resolveDns,
|
|
271
|
+
allowPrivate: options.allowPrivate
|
|
272
|
+
};
|
|
183
273
|
const controller = new AbortController;
|
|
184
274
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
185
275
|
try {
|
|
186
|
-
let
|
|
276
|
+
let current = await resolveSafeOutboundTarget(input, urlOptions);
|
|
187
277
|
let redirectCount = 0;
|
|
188
278
|
while (true) {
|
|
189
|
-
const
|
|
279
|
+
const address = current.addresses[0];
|
|
280
|
+
const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
|
|
281
|
+
const headers = new Headers(init.headers);
|
|
282
|
+
if (address && !headers.has("host")) {
|
|
283
|
+
headers.set("Host", current.url.host);
|
|
284
|
+
}
|
|
285
|
+
const fetchInit = {
|
|
190
286
|
...init,
|
|
287
|
+
headers,
|
|
191
288
|
signal: controller.signal,
|
|
192
289
|
redirect: "manual"
|
|
193
|
-
}
|
|
290
|
+
};
|
|
291
|
+
if (address) {
|
|
292
|
+
fetchInit.tls = { serverName: current.url.hostname };
|
|
293
|
+
}
|
|
294
|
+
const response = await fetch(fetchUrl, fetchInit);
|
|
194
295
|
if (response.status >= 300 && response.status < 400) {
|
|
195
296
|
const location = response.headers.get("location");
|
|
196
297
|
if (!location || redirectCount >= maxRedirects) {
|
|
197
298
|
return response;
|
|
198
299
|
}
|
|
199
|
-
|
|
300
|
+
current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
|
|
200
301
|
redirectCount += 1;
|
|
201
302
|
continue;
|
|
202
303
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/security/safePath.ts
|
|
3
|
+
import { isAbsolute, resolve, sep } from "path";
|
|
4
|
+
function assertPathUnderRoot(rootDirectory, userPath) {
|
|
5
|
+
const trimmed = userPath.trim();
|
|
6
|
+
if (trimmed.length === 0) {
|
|
7
|
+
throw new Error("Path must not be empty.");
|
|
8
|
+
}
|
|
9
|
+
if (trimmed.includes("\x00")) {
|
|
10
|
+
throw new Error("Path must not contain a null byte.");
|
|
11
|
+
}
|
|
12
|
+
const root = resolve(rootDirectory);
|
|
13
|
+
const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
|
|
14
|
+
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
15
|
+
return candidate;
|
|
16
|
+
}
|
|
17
|
+
throw new Error("Path escapes the storage root.");
|
|
18
|
+
}
|
|
19
|
+
function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
|
|
20
|
+
return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
assertPathUnderRoot,
|
|
24
|
+
assertUrlPathUnderRoot
|
|
25
|
+
};
|
|
@@ -30,14 +30,21 @@ function parseScimTenantTokens(raw) {
|
|
|
30
30
|
return tokens;
|
|
31
31
|
}
|
|
32
32
|
function resolveScimTenantFromToken(token) {
|
|
33
|
+
const presented = token.trim();
|
|
34
|
+
if (presented.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
33
37
|
const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
|
|
34
38
|
for (const [tenantId, expectedToken] of tenantTokens) {
|
|
35
|
-
if (timingSafeCompareString(
|
|
39
|
+
if (timingSafeCompareString(presented, expectedToken)) {
|
|
36
40
|
return tenantId;
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
|
-
const fallbackToken = process.env.SCIM_BEARER_TOKEN ?? "";
|
|
40
|
-
if (
|
|
43
|
+
const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
|
|
44
|
+
if (fallbackToken.length === 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (timingSafeCompareString(presented, fallbackToken)) {
|
|
41
48
|
return 1;
|
|
42
49
|
}
|
|
43
50
|
return null;
|
|
@@ -85,6 +85,17 @@ function sdkClientClassName() {
|
|
|
85
85
|
return fromName ? `${fromName}Client` : "AppClient";
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ../../src/core/security/timingSafeCompare.ts
|
|
89
|
+
import { timingSafeEqual } from "crypto";
|
|
90
|
+
function timingSafeCompareString(left, right) {
|
|
91
|
+
const leftBuffer = Buffer.from(left);
|
|
92
|
+
const rightBuffer = Buffer.from(right);
|
|
93
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
// ../../src/core/security/totp.ts
|
|
89
100
|
function decodeBase32(input) {
|
|
90
101
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
@@ -131,7 +142,7 @@ function buildOtpauthUrl(options) {
|
|
|
131
142
|
});
|
|
132
143
|
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
133
144
|
}
|
|
134
|
-
function generateTotp(secret, counter, digits = 6) {
|
|
145
|
+
function generateTotp(secret, counter = Math.floor(Date.now() / 30000), digits = 6) {
|
|
135
146
|
const key = decodeBase32(secret);
|
|
136
147
|
const buffer = Buffer.alloc(8);
|
|
137
148
|
buffer.writeBigUInt64BE(BigInt(counter));
|
|
@@ -151,12 +162,13 @@ function verifyTotp(secret, token, window = 1) {
|
|
|
151
162
|
return false;
|
|
152
163
|
}
|
|
153
164
|
const timestep = Math.floor(Date.now() / 30000);
|
|
165
|
+
let matched = false;
|
|
154
166
|
for (let offset = -window;offset <= window; offset += 1) {
|
|
155
|
-
if (generateTotp(secret, timestep + offset)
|
|
156
|
-
|
|
167
|
+
if (timingSafeCompareString(generateTotp(secret, timestep + offset), normalized)) {
|
|
168
|
+
matched = true;
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
|
-
return
|
|
171
|
+
return matched;
|
|
160
172
|
}
|
|
161
173
|
export {
|
|
162
174
|
buildOtpauthUrl,
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/storage/storage.ts
|
|
3
3
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from "path";
|
|
5
5
|
var {S3Client } = globalThis.Bun;
|
|
6
6
|
|
|
7
|
+
// ../../src/core/security/safePath.ts
|
|
8
|
+
import { isAbsolute, resolve, sep } from "path";
|
|
9
|
+
function assertPathUnderRoot(rootDirectory, userPath) {
|
|
10
|
+
const trimmed = userPath.trim();
|
|
11
|
+
if (trimmed.length === 0) {
|
|
12
|
+
throw new Error("Path must not be empty.");
|
|
13
|
+
}
|
|
14
|
+
if (trimmed.includes("\x00")) {
|
|
15
|
+
throw new Error("Path must not contain a null byte.");
|
|
16
|
+
}
|
|
17
|
+
const root = resolve(rootDirectory);
|
|
18
|
+
const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
|
|
19
|
+
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
20
|
+
return candidate;
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Path escapes the storage root.");
|
|
23
|
+
}
|
|
24
|
+
function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
|
|
25
|
+
return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ../../src/core/storage/storage.ts
|
|
7
29
|
class LocalStorageDriver {
|
|
8
30
|
rootDirectory;
|
|
9
31
|
constructor(rootDirectory) {
|
|
@@ -13,7 +35,7 @@ class LocalStorageDriver {
|
|
|
13
35
|
return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
|
|
14
36
|
}
|
|
15
37
|
resolvePath(path) {
|
|
16
|
-
return
|
|
38
|
+
return assertPathUnderRoot(this.resolveRootDirectory(), path);
|
|
17
39
|
}
|
|
18
40
|
async put(path, contents) {
|
|
19
41
|
const absolutePath = this.resolvePath(path);
|
|
@@ -53,28 +53,50 @@ async function applyBypassToTransaction(transaction, bypass) {
|
|
|
53
53
|
bypass ? "true" : "false"
|
|
54
54
|
]);
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
56
|
+
function stringifyBypassIdentifier(identifier) {
|
|
57
|
+
if (typeof identifier === "number") {
|
|
58
|
+
return String(identifier);
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
return identifier.trim();
|
|
61
|
+
}
|
|
62
|
+
async function applyIdentifierToTransaction(transaction, identifier) {
|
|
63
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
|
|
64
|
+
stringifyBypassIdentifier(identifier)
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
function assertBypassIdentifier(identifier) {
|
|
68
|
+
if (typeof identifier === "number") {
|
|
69
|
+
if (!Number.isInteger(identifier) || identifier <= 0) {
|
|
70
|
+
throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
|
|
67
71
|
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (typeof identifier === "string" && identifier.trim() !== "") {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
|
|
78
|
+
}
|
|
79
|
+
async function runWithScopedTenantTransaction(apply, callback) {
|
|
80
|
+
if (!isRlsTenancy()) {
|
|
81
|
+
return await callback();
|
|
68
82
|
}
|
|
69
83
|
const pool = getDefaultDatabasePool();
|
|
70
84
|
if (typeof pool.begin !== "function") {
|
|
71
85
|
throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
72
86
|
}
|
|
73
87
|
return await pool.begin(async (transaction) => {
|
|
74
|
-
await
|
|
88
|
+
await apply(transaction);
|
|
75
89
|
return await runWithDatabaseConnection(transaction, callback);
|
|
76
90
|
});
|
|
77
91
|
}
|
|
92
|
+
async function runWithMigrationBypass(callback) {
|
|
93
|
+
return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
|
|
94
|
+
}
|
|
95
|
+
async function runWithMigrationBypassForIdentifier(identifier, callback) {
|
|
96
|
+
assertBypassIdentifier(identifier);
|
|
97
|
+
return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
|
|
98
|
+
}
|
|
78
99
|
export {
|
|
79
|
-
runWithMigrationBypass
|
|
100
|
+
runWithMigrationBypass,
|
|
101
|
+
runWithMigrationBypassForIdentifier
|
|
80
102
|
};
|