@getstrata/core 1.0.9 → 1.1.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 +48 -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
|
@@ -156,6 +156,69 @@ function readSpaPrefix() {
|
|
|
156
156
|
// ../../src/core/view/webErrorView.ts
|
|
157
157
|
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
158
158
|
|
|
159
|
+
// ../../src/core/http/safeInternalPath.ts
|
|
160
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
161
|
+
function looksLikeExternalTarget(value) {
|
|
162
|
+
const trimmed = value.trim();
|
|
163
|
+
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const decoded = decodeURIComponent(trimmed);
|
|
171
|
+
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
function sanitizeNestedQuery(search) {
|
|
180
|
+
if (!search) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
184
|
+
const next = new URLSearchParams;
|
|
185
|
+
for (const [key, value] of params.entries()) {
|
|
186
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
187
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
188
|
+
next.set(key, sanitized);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
next.set(key, value);
|
|
195
|
+
}
|
|
196
|
+
const serialized = next.toString();
|
|
197
|
+
return serialized ? `?${serialized}` : "";
|
|
198
|
+
}
|
|
199
|
+
function sanitizeInternalPath(raw, fallback = "/") {
|
|
200
|
+
const trimmed = raw.trim();
|
|
201
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
202
|
+
return fallback;
|
|
203
|
+
}
|
|
204
|
+
const queryIndex = trimmed.indexOf("?");
|
|
205
|
+
const hashIndex = trimmed.indexOf("#");
|
|
206
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
207
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
208
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
209
|
+
return fallback;
|
|
210
|
+
}
|
|
211
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
212
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
213
|
+
}
|
|
214
|
+
function safeInternalRedirectPath(request, fallback = "/") {
|
|
215
|
+
const url = new URL(request.url);
|
|
216
|
+
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
217
|
+
}
|
|
218
|
+
function loginRedirectLocation(request) {
|
|
219
|
+
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
159
222
|
// ../../src/core/view/htmlResponse.ts
|
|
160
223
|
function htmlResponse(html, init = {}) {
|
|
161
224
|
return new Response(html, {
|
|
@@ -16,7 +16,7 @@ function buildPaginationMeta(input) {
|
|
|
16
16
|
// ../../src/core/http/validation.ts
|
|
17
17
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
18
18
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
19
|
-
import {
|
|
19
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
20
20
|
function buildRequestCacheKey(fallbackPath, request) {
|
|
21
21
|
if (!request) {
|
|
22
22
|
return fallbackPath;
|
|
@@ -24,7 +24,7 @@ function buildRequestCacheKey(fallbackPath, request) {
|
|
|
24
24
|
const url = new URL(request.url);
|
|
25
25
|
const user = currentAuthUser();
|
|
26
26
|
const authScope = user ? `u:${user.id}` : "guest";
|
|
27
|
-
const tenantScope = `t:${
|
|
27
|
+
const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
|
|
28
28
|
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
29
29
|
}
|
|
30
30
|
function getQueryParams(request) {
|
|
@@ -31,6 +31,7 @@ function requestPrefersJson(request) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ../../src/core/http/safeInternalPath.ts
|
|
34
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
34
35
|
function looksLikeExternalTarget(value) {
|
|
35
36
|
const trimmed = value.trim();
|
|
36
37
|
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
@@ -49,11 +50,40 @@ function looksLikeExternalTarget(value) {
|
|
|
49
50
|
}
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
53
|
+
function sanitizeNestedQuery(search) {
|
|
54
|
+
if (!search) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
58
|
+
const next = new URLSearchParams;
|
|
59
|
+
for (const [key, value] of params.entries()) {
|
|
60
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
61
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
62
|
+
next.set(key, sanitized);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
next.set(key, value);
|
|
69
|
+
}
|
|
70
|
+
const serialized = next.toString();
|
|
71
|
+
return serialized ? `?${serialized}` : "";
|
|
72
|
+
}
|
|
52
73
|
function sanitizeInternalPath(raw, fallback = "/") {
|
|
53
|
-
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
const queryIndex = trimmed.indexOf("?");
|
|
79
|
+
const hashIndex = trimmed.indexOf("#");
|
|
80
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
81
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
82
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
54
83
|
return fallback;
|
|
55
84
|
}
|
|
56
|
-
|
|
85
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
86
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
57
87
|
}
|
|
58
88
|
function safeInternalRedirectPath(request, fallback = "/") {
|
|
59
89
|
const url = new URL(request.url);
|
|
@@ -70,7 +100,10 @@ function createRequirePasswordConfirmMiddleware() {
|
|
|
70
100
|
return async (request, next) => {
|
|
71
101
|
const user = currentAuthUser();
|
|
72
102
|
if (!user) {
|
|
73
|
-
|
|
103
|
+
if (requestPrefersJson(request)) {
|
|
104
|
+
return Response.json({ error: "Authentication required." }, { status: 401 });
|
|
105
|
+
}
|
|
106
|
+
return Response.redirect("/login", 302);
|
|
74
107
|
}
|
|
75
108
|
const userId = resolveUserId(user);
|
|
76
109
|
if (hasFreshPasswordConfirmation(request, userId)) {
|
|
@@ -31,6 +31,7 @@ function requestPrefersJson(request) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ../../src/core/http/safeInternalPath.ts
|
|
34
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
34
35
|
function looksLikeExternalTarget(value) {
|
|
35
36
|
const trimmed = value.trim();
|
|
36
37
|
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
@@ -49,11 +50,40 @@ function looksLikeExternalTarget(value) {
|
|
|
49
50
|
}
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
53
|
+
function sanitizeNestedQuery(search) {
|
|
54
|
+
if (!search) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
58
|
+
const next = new URLSearchParams;
|
|
59
|
+
for (const [key, value] of params.entries()) {
|
|
60
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
61
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
62
|
+
next.set(key, sanitized);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
next.set(key, value);
|
|
69
|
+
}
|
|
70
|
+
const serialized = next.toString();
|
|
71
|
+
return serialized ? `?${serialized}` : "";
|
|
72
|
+
}
|
|
52
73
|
function sanitizeInternalPath(raw, fallback = "/") {
|
|
53
|
-
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
const queryIndex = trimmed.indexOf("?");
|
|
79
|
+
const hashIndex = trimmed.indexOf("#");
|
|
80
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
81
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
82
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
54
83
|
return fallback;
|
|
55
84
|
}
|
|
56
|
-
|
|
85
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
86
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
57
87
|
}
|
|
58
88
|
function safeInternalRedirectPath(request, fallback = "/") {
|
|
59
89
|
const url = new URL(request.url);
|
|
@@ -78,21 +78,25 @@ function mapDatabaseError(error) {
|
|
|
78
78
|
const sqlState = getPostgresSqlState(error);
|
|
79
79
|
switch (sqlState) {
|
|
80
80
|
case "23505":
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
81
|
+
if (error.detail) {
|
|
82
|
+
console.error("[database] unique violation", error.detail);
|
|
83
|
+
}
|
|
84
|
+
return new ConflictError("A record with these values already exists.");
|
|
84
85
|
case "23503":
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
86
|
+
if (error.detail) {
|
|
87
|
+
console.error("[database] foreign-key violation", error.detail);
|
|
88
|
+
}
|
|
89
|
+
return new UnprocessableEntityError("Referenced record does not exist.");
|
|
88
90
|
case "23502":
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
91
|
+
if (error.detail) {
|
|
92
|
+
console.error("[database] not-null violation", error.detail);
|
|
93
|
+
}
|
|
94
|
+
return new BadRequestError("Required field is missing.");
|
|
92
95
|
case "23514":
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
+
if (error.detail) {
|
|
97
|
+
console.error("[database] check violation", error.detail);
|
|
98
|
+
}
|
|
99
|
+
return new BadRequestError("Value violates a database constraint.");
|
|
96
100
|
default:
|
|
97
101
|
return new InternalServerError("Database operation failed.");
|
|
98
102
|
}
|
|
@@ -104,6 +108,18 @@ async function withDatabaseErrorHandling(operation) {
|
|
|
104
108
|
throw mapDatabaseError(error);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
111
|
+
function isUniqueConstraintError(error) {
|
|
112
|
+
if (!isPostgresError(error)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (error.code === "SQLITE_CONSTRAINT_UNIQUE" || error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (error.errno === 1062) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return getPostgresSqlState(error) === "23505";
|
|
122
|
+
}
|
|
107
123
|
|
|
108
124
|
// ../../src/core/http/webErrorResponse.ts
|
|
109
125
|
import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
|
|
@@ -171,6 +187,69 @@ function readSpaPrefix() {
|
|
|
171
187
|
// ../../src/core/view/webErrorView.ts
|
|
172
188
|
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
173
189
|
|
|
190
|
+
// ../../src/core/http/safeInternalPath.ts
|
|
191
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
192
|
+
function looksLikeExternalTarget(value) {
|
|
193
|
+
const trimmed = value.trim();
|
|
194
|
+
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const decoded = decodeURIComponent(trimmed);
|
|
202
|
+
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
function sanitizeNestedQuery(search) {
|
|
211
|
+
if (!search) {
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
215
|
+
const next = new URLSearchParams;
|
|
216
|
+
for (const [key, value] of params.entries()) {
|
|
217
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
218
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
219
|
+
next.set(key, sanitized);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
next.set(key, value);
|
|
226
|
+
}
|
|
227
|
+
const serialized = next.toString();
|
|
228
|
+
return serialized ? `?${serialized}` : "";
|
|
229
|
+
}
|
|
230
|
+
function sanitizeInternalPath(raw, fallback = "/") {
|
|
231
|
+
const trimmed = raw.trim();
|
|
232
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
233
|
+
return fallback;
|
|
234
|
+
}
|
|
235
|
+
const queryIndex = trimmed.indexOf("?");
|
|
236
|
+
const hashIndex = trimmed.indexOf("#");
|
|
237
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
238
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
239
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
240
|
+
return fallback;
|
|
241
|
+
}
|
|
242
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
243
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
244
|
+
}
|
|
245
|
+
function safeInternalRedirectPath(request, fallback = "/") {
|
|
246
|
+
const url = new URL(request.url);
|
|
247
|
+
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
248
|
+
}
|
|
249
|
+
function loginRedirectLocation(request) {
|
|
250
|
+
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
174
253
|
// ../../src/core/view/htmlResponse.ts
|
|
175
254
|
function htmlResponse(html, init = {}) {
|
|
176
255
|
return new Response(html, {
|
|
@@ -261,39 +340,6 @@ function requestPrefersJson(request) {
|
|
|
261
340
|
return false;
|
|
262
341
|
}
|
|
263
342
|
|
|
264
|
-
// ../../src/core/http/safeInternalPath.ts
|
|
265
|
-
function looksLikeExternalTarget(value) {
|
|
266
|
-
const trimmed = value.trim();
|
|
267
|
-
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
271
|
-
return true;
|
|
272
|
-
}
|
|
273
|
-
try {
|
|
274
|
-
const decoded = decodeURIComponent(trimmed);
|
|
275
|
-
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
} catch {
|
|
279
|
-
return true;
|
|
280
|
-
}
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
function sanitizeInternalPath(raw, fallback = "/") {
|
|
284
|
-
if (looksLikeExternalTarget(raw)) {
|
|
285
|
-
return fallback;
|
|
286
|
-
}
|
|
287
|
-
return raw;
|
|
288
|
-
}
|
|
289
|
-
function safeInternalRedirectPath(request, fallback = "/") {
|
|
290
|
-
const url = new URL(request.url);
|
|
291
|
-
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
292
|
-
}
|
|
293
|
-
function loginRedirectLocation(request) {
|
|
294
|
-
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
343
|
// ../../src/core/http/webErrorResponse.ts
|
|
298
344
|
function normalizeFieldErrors(details) {
|
|
299
345
|
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
@@ -402,7 +448,17 @@ function withJsonErrorHandling(handler) {
|
|
|
402
448
|
}
|
|
403
449
|
};
|
|
404
450
|
}
|
|
451
|
+
function createJsonErrorMiddleware() {
|
|
452
|
+
return async (_request, next) => {
|
|
453
|
+
try {
|
|
454
|
+
return await next();
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return errorResponse(error);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
405
460
|
export {
|
|
461
|
+
createJsonErrorMiddleware,
|
|
406
462
|
createdResponse,
|
|
407
463
|
errorResponse,
|
|
408
464
|
jsonResponse,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ../../src/core/http/validation.ts
|
|
3
3
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
4
4
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
5
|
-
import {
|
|
5
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
6
6
|
function buildRequestCacheKey(fallbackPath, request) {
|
|
7
7
|
if (!request) {
|
|
8
8
|
return fallbackPath;
|
|
@@ -10,7 +10,7 @@ function buildRequestCacheKey(fallbackPath, request) {
|
|
|
10
10
|
const url = new URL(request.url);
|
|
11
11
|
const user = currentAuthUser();
|
|
12
12
|
const authScope = user ? `u:${user.id}` : "guest";
|
|
13
|
-
const tenantScope = `t:${
|
|
13
|
+
const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
|
|
14
14
|
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
15
15
|
}
|
|
16
16
|
function getQueryParams(request) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/safeInternalPath.ts
|
|
3
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
3
4
|
function looksLikeExternalTarget(value) {
|
|
4
5
|
const trimmed = value.trim();
|
|
5
6
|
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
@@ -18,11 +19,40 @@ function looksLikeExternalTarget(value) {
|
|
|
18
19
|
}
|
|
19
20
|
return false;
|
|
20
21
|
}
|
|
22
|
+
function sanitizeNestedQuery(search) {
|
|
23
|
+
if (!search) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
27
|
+
const next = new URLSearchParams;
|
|
28
|
+
for (const [key, value] of params.entries()) {
|
|
29
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
30
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
31
|
+
next.set(key, sanitized);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
next.set(key, value);
|
|
38
|
+
}
|
|
39
|
+
const serialized = next.toString();
|
|
40
|
+
return serialized ? `?${serialized}` : "";
|
|
41
|
+
}
|
|
21
42
|
function sanitizeInternalPath(raw, fallback = "/") {
|
|
22
|
-
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
const queryIndex = trimmed.indexOf("?");
|
|
48
|
+
const hashIndex = trimmed.indexOf("#");
|
|
49
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
50
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
51
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
23
52
|
return fallback;
|
|
24
53
|
}
|
|
25
|
-
|
|
54
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
55
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
26
56
|
}
|
|
27
57
|
function safeInternalRedirectPath(request, fallback = "/") {
|
|
28
58
|
const url = new URL(request.url);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/scimThrottleMiddleware.ts
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
var {RedisClient } = globalThis.Bun;
|
|
4
5
|
|
|
5
6
|
// ../../src/core/runtime/appEnv.ts
|
|
@@ -120,7 +121,12 @@ function readClientIp(request, env = process.env) {
|
|
|
120
121
|
// ../../src/core/http/scimThrottleMiddleware.ts
|
|
121
122
|
var memoryBuckets = new Map;
|
|
122
123
|
function resolveScimIdentity(request) {
|
|
123
|
-
|
|
124
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
125
|
+
const presented = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
|
|
126
|
+
if (presented.length > 0) {
|
|
127
|
+
return createHash("sha256").update(presented).digest("hex");
|
|
128
|
+
}
|
|
129
|
+
return readClientIp(request) ?? "unknown";
|
|
124
130
|
}
|
|
125
131
|
function throttleFromMemory(key, maxAttempts, decaySeconds) {
|
|
126
132
|
const now = Date.now();
|
|
@@ -108,7 +108,7 @@ function applyConditionalGet(request, response, etag) {
|
|
|
108
108
|
// ../../src/core/http/validation.ts
|
|
109
109
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
110
110
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
111
|
-
import {
|
|
111
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
112
112
|
function buildRequestCacheKey(fallbackPath, request) {
|
|
113
113
|
if (!request) {
|
|
114
114
|
return fallbackPath;
|
|
@@ -116,7 +116,7 @@ function buildRequestCacheKey(fallbackPath, request) {
|
|
|
116
116
|
const url = new URL(request.url);
|
|
117
117
|
const user = currentAuthUser();
|
|
118
118
|
const authScope = user ? `u:${user.id}` : "guest";
|
|
119
|
-
const tenantScope = `t:${
|
|
119
|
+
const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
|
|
120
120
|
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
121
121
|
}
|
|
122
122
|
function getQueryParams(request) {
|
|
@@ -27,69 +27,6 @@ function envFlagEnabled(value) {
|
|
|
27
27
|
return value === "true";
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// ../../src/core/runtime/appKeyPrefix.ts
|
|
31
|
-
function appKeyPrefix() {
|
|
32
|
-
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
33
|
-
}
|
|
34
|
-
function appCookieName(kind) {
|
|
35
|
-
return `${appKeyPrefix()}_${kind}`;
|
|
36
|
-
}
|
|
37
|
-
function appDevSecret(kind) {
|
|
38
|
-
return `${appKeyPrefix()}-dev-${kind}`;
|
|
39
|
-
}
|
|
40
|
-
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
41
|
-
for (const name of names) {
|
|
42
|
-
const value = env[name]?.trim();
|
|
43
|
-
if (value) {
|
|
44
|
-
return value;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
if (isProductionEnv(env)) {
|
|
48
|
-
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
49
|
-
}
|
|
50
|
-
return appDevSecret(devKind);
|
|
51
|
-
}
|
|
52
|
-
function namespacedRedisKey(kind) {
|
|
53
|
-
return `${appKeyPrefix()}:${kind}`;
|
|
54
|
-
}
|
|
55
|
-
function smtpEhloHost() {
|
|
56
|
-
const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
|
|
57
|
-
const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
|
|
58
|
-
return safe || "strata.local";
|
|
59
|
-
}
|
|
60
|
-
function siemEventType() {
|
|
61
|
-
return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
|
|
62
|
-
}
|
|
63
|
-
function appUserAgent() {
|
|
64
|
-
return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
|
|
65
|
-
}
|
|
66
|
-
function otelServiceName() {
|
|
67
|
-
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
68
|
-
}
|
|
69
|
-
function appDisplayName() {
|
|
70
|
-
return process.env.APP_NAME?.trim() || "Strata";
|
|
71
|
-
}
|
|
72
|
-
function appEnv() {
|
|
73
|
-
return process.env.APP_ENV?.trim() || "local";
|
|
74
|
-
}
|
|
75
|
-
function appUrl() {
|
|
76
|
-
return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
|
|
77
|
-
}
|
|
78
|
-
function apiPrefix() {
|
|
79
|
-
const raw = process.env.API_PREFIX?.trim() || "/api/v1";
|
|
80
|
-
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
81
|
-
const trimmed = withSlash.replace(/\/+$/, "");
|
|
82
|
-
return trimmed || "/api/v1";
|
|
83
|
-
}
|
|
84
|
-
function sdkClientClassName() {
|
|
85
|
-
const override = process.env.APP_SDK_CLASS?.trim();
|
|
86
|
-
if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
|
|
87
|
-
return override;
|
|
88
|
-
}
|
|
89
|
-
const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
|
|
90
|
-
return fromName ? `${fromName}Client` : "AppClient";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
30
|
// ../../src/core/http/securityHeadersMiddleware.ts
|
|
94
31
|
function createSecurityHeadersMiddleware(options = {}) {
|
|
95
32
|
return async (request, next) => {
|
|
@@ -106,8 +43,10 @@ function createSecurityHeadersMiddleware(options = {}) {
|
|
|
106
43
|
headers.set("X-Frame-Options", "DENY");
|
|
107
44
|
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
108
45
|
headers.set("X-XSS-Protection", "0");
|
|
46
|
+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
47
|
+
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
109
48
|
headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response, { ...options, nonce }));
|
|
110
|
-
if (
|
|
49
|
+
if (isProductionEnv()) {
|
|
111
50
|
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
112
51
|
}
|
|
113
52
|
return new Response(response.body, {
|
|
@@ -159,11 +159,12 @@ function hasValidSignature(input) {
|
|
|
159
159
|
return false;
|
|
160
160
|
}
|
|
161
161
|
const expires = url.searchParams.get("expires");
|
|
162
|
-
if (expires) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
if (!expires) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const expiresAt = Number.parseInt(expires, 10);
|
|
166
|
+
if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
|
|
167
|
+
return false;
|
|
167
168
|
}
|
|
168
169
|
const expected = signCanonicalPayload(url.pathname, sortedQueryString(url.searchParams));
|
|
169
170
|
return timingSafeCompareString(signature, expected);
|
|
@@ -161,6 +161,69 @@ function readSpaPrefix() {
|
|
|
161
161
|
// ../../src/core/view/webErrorView.ts
|
|
162
162
|
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
163
163
|
|
|
164
|
+
// ../../src/core/http/safeInternalPath.ts
|
|
165
|
+
var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
|
|
166
|
+
function looksLikeExternalTarget(value) {
|
|
167
|
+
const trimmed = value.trim();
|
|
168
|
+
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const decoded = decodeURIComponent(trimmed);
|
|
176
|
+
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
function sanitizeNestedQuery(search) {
|
|
185
|
+
if (!search) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
189
|
+
const next = new URLSearchParams;
|
|
190
|
+
for (const [key, value] of params.entries()) {
|
|
191
|
+
if (NESTED_REDIRECT_KEYS.has(key)) {
|
|
192
|
+
const sanitized = sanitizeInternalPath(value, "/");
|
|
193
|
+
next.set(key, sanitized);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (looksLikeExternalTarget(value) && value.includes("://")) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
next.set(key, value);
|
|
200
|
+
}
|
|
201
|
+
const serialized = next.toString();
|
|
202
|
+
return serialized ? `?${serialized}` : "";
|
|
203
|
+
}
|
|
204
|
+
function sanitizeInternalPath(raw, fallback = "/") {
|
|
205
|
+
const trimmed = raw.trim();
|
|
206
|
+
if (looksLikeExternalTarget(trimmed)) {
|
|
207
|
+
return fallback;
|
|
208
|
+
}
|
|
209
|
+
const queryIndex = trimmed.indexOf("?");
|
|
210
|
+
const hashIndex = trimmed.indexOf("#");
|
|
211
|
+
const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
|
|
212
|
+
const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
|
|
213
|
+
if (looksLikeExternalTarget(pathname)) {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
|
|
217
|
+
return `${pathname}${sanitizeNestedQuery(search)}`;
|
|
218
|
+
}
|
|
219
|
+
function safeInternalRedirectPath(request, fallback = "/") {
|
|
220
|
+
const url = new URL(request.url);
|
|
221
|
+
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
222
|
+
}
|
|
223
|
+
function loginRedirectLocation(request) {
|
|
224
|
+
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
164
227
|
// ../../src/core/view/htmlResponse.ts
|
|
165
228
|
function htmlResponse(html, init = {}) {
|
|
166
229
|
return new Response(html, {
|