@getstrata/core 1.0.8 → 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 +52 -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
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// ../../src/core/runtime/appEnv.ts
|
|
3
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
4
|
+
function normalizeEnvValue(value) {
|
|
5
|
+
return (value ?? "").trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function isProductionEnv(env = process.env) {
|
|
8
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
9
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
10
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (appEnv === "") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
17
|
+
}
|
|
18
|
+
function envFlagEnabled(value) {
|
|
19
|
+
return value === "true";
|
|
20
|
+
}
|
|
21
|
+
|
|
2
22
|
// ../../src/core/crypto/fieldEncryption.ts
|
|
3
23
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
4
24
|
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
@@ -26,7 +46,7 @@ function isFieldEncryptionEnabled() {
|
|
|
26
46
|
if (featureFlag === "true") {
|
|
27
47
|
return true;
|
|
28
48
|
}
|
|
29
|
-
return (
|
|
49
|
+
return Boolean(resolveEncryptionKey());
|
|
30
50
|
}
|
|
31
51
|
function encryptField(plaintext, key) {
|
|
32
52
|
const iv = randomBytes(IV_LENGTH);
|
|
@@ -84,8 +104,8 @@ function emailLookupForQuery(email) {
|
|
|
84
104
|
// ../../src/core/crypto/mfaSecret.ts
|
|
85
105
|
function protectMfaSecret(secret) {
|
|
86
106
|
const key = resolveEncryptionKey();
|
|
87
|
-
if (!
|
|
88
|
-
|
|
107
|
+
if (!key) {
|
|
108
|
+
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
89
109
|
}
|
|
90
110
|
return encryptField(secret, key);
|
|
91
111
|
}
|
|
@@ -93,10 +113,16 @@ function revealMfaSecret(stored) {
|
|
|
93
113
|
if (!stored) {
|
|
94
114
|
return null;
|
|
95
115
|
}
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
if (!stored.startsWith("enc:v1:")) {
|
|
117
|
+
if (isProductionEnv() || resolveEncryptionKey()) {
|
|
118
|
+
throw new Error("MFA secrets must be encrypted.");
|
|
119
|
+
}
|
|
98
120
|
return stored;
|
|
99
121
|
}
|
|
122
|
+
const key = resolveEncryptionKey();
|
|
123
|
+
if (!key) {
|
|
124
|
+
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
125
|
+
}
|
|
100
126
|
return decryptField(stored, key);
|
|
101
127
|
}
|
|
102
128
|
export {
|
|
@@ -75,21 +75,25 @@ function mapDatabaseError(error) {
|
|
|
75
75
|
const sqlState = getPostgresSqlState(error);
|
|
76
76
|
switch (sqlState) {
|
|
77
77
|
case "23505":
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
78
|
+
if (error.detail) {
|
|
79
|
+
console.error("[database] unique violation", error.detail);
|
|
80
|
+
}
|
|
81
|
+
return new ConflictError("A record with these values already exists.");
|
|
81
82
|
case "23503":
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
83
|
+
if (error.detail) {
|
|
84
|
+
console.error("[database] foreign-key violation", error.detail);
|
|
85
|
+
}
|
|
86
|
+
return new UnprocessableEntityError("Referenced record does not exist.");
|
|
85
87
|
case "23502":
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
88
|
+
if (error.detail) {
|
|
89
|
+
console.error("[database] not-null violation", error.detail);
|
|
90
|
+
}
|
|
91
|
+
return new BadRequestError("Required field is missing.");
|
|
89
92
|
case "23514":
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
+
if (error.detail) {
|
|
94
|
+
console.error("[database] check violation", error.detail);
|
|
95
|
+
}
|
|
96
|
+
return new BadRequestError("Value violates a database constraint.");
|
|
93
97
|
default:
|
|
94
98
|
return new InternalServerError("Database operation failed.");
|
|
95
99
|
}
|
|
@@ -101,8 +105,21 @@ async function withDatabaseErrorHandling(operation) {
|
|
|
101
105
|
throw mapDatabaseError(error);
|
|
102
106
|
}
|
|
103
107
|
}
|
|
108
|
+
function isUniqueConstraintError(error) {
|
|
109
|
+
if (!isPostgresError(error)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (error.code === "SQLITE_CONSTRAINT_UNIQUE" || error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (error.errno === 1062) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return getPostgresSqlState(error) === "23505";
|
|
119
|
+
}
|
|
104
120
|
export {
|
|
105
121
|
isPostgresError,
|
|
122
|
+
isUniqueConstraintError,
|
|
106
123
|
mapDatabaseError,
|
|
107
124
|
withDatabaseErrorHandling
|
|
108
125
|
};
|
|
@@ -858,7 +858,7 @@ class BelongsToManyRelationQuery {
|
|
|
858
858
|
}
|
|
859
859
|
async get() {
|
|
860
860
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
861
|
-
const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
|
|
861
|
+
const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
|
|
862
862
|
if (pivotRows.length === 0) {
|
|
863
863
|
return [];
|
|
864
864
|
}
|
|
@@ -881,7 +881,7 @@ class BelongsToManyRelationQuery {
|
|
|
881
881
|
}
|
|
882
882
|
async count() {
|
|
883
883
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
884
|
-
const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
|
|
884
|
+
const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
|
|
885
885
|
return Number(rows[0]?.count ?? 0);
|
|
886
886
|
}
|
|
887
887
|
async pluck(column, keyBy) {
|
|
@@ -893,7 +893,7 @@ class BelongsToManyRelationQuery {
|
|
|
893
893
|
}
|
|
894
894
|
async relatedQuery() {
|
|
895
895
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
896
|
-
const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
|
|
896
|
+
const pivotRows = await this.connection().unsafe(`SELECT * FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
|
|
897
897
|
if (pivotRows.length === 0) {
|
|
898
898
|
return null;
|
|
899
899
|
}
|
|
@@ -916,18 +916,18 @@ class BelongsToManyRelationQuery {
|
|
|
916
916
|
const list = Array.isArray(ids) ? ids : [ids];
|
|
917
917
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
918
918
|
const extraKeys = Object.keys(this.pivotValues);
|
|
919
|
-
const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.join(", ")}` : "";
|
|
919
|
+
const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.map((key) => quoteIdentifier(key)).join(", ")}` : "";
|
|
920
920
|
const extraPlaceholders = extraKeys.map((_, index) => `$${index + 3}`).join(", ");
|
|
921
921
|
const extraValues = extraKeys.map((key) => this.pivotValues[key]);
|
|
922
922
|
for (const id of list) {
|
|
923
|
-
await this.connection().unsafe(extraKeys.length > 0 ? `INSERT INTO ${this.relation.pivotTable} (${String(this.relation.foreignPivotKey)}, ${String(this.relation.relatedPivotKey)}${extraColumns}) VALUES ($1, $2, ${extraPlaceholders})` : `INSERT INTO ${this.relation.pivotTable} (${String(this.relation.foreignPivotKey)}, ${String(this.relation.relatedPivotKey)}) VALUES ($1, $2)`, [parentId, id, ...extraValues]);
|
|
923
|
+
await this.connection().unsafe(extraKeys.length > 0 ? `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}${extraColumns}) VALUES ($1, $2, ${extraPlaceholders})` : `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}) VALUES ($1, $2)`, [parentId, id, ...extraValues]);
|
|
924
924
|
}
|
|
925
925
|
}
|
|
926
926
|
async toggle(ids) {
|
|
927
927
|
const list = Array.isArray(ids) ? ids : [ids];
|
|
928
928
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
929
929
|
for (const id of list) {
|
|
930
|
-
const existing = await this.connection().unsafe(`SELECT 1 FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1 AND ${String(this.relation.relatedPivotKey)} = $2 LIMIT 1`, [parentId, id]);
|
|
930
|
+
const existing = await this.connection().unsafe(`SELECT 1 FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} = $2 LIMIT 1`, [parentId, id]);
|
|
931
931
|
if (existing.length > 0) {
|
|
932
932
|
await this.detach(id);
|
|
933
933
|
} else {
|
|
@@ -938,12 +938,12 @@ class BelongsToManyRelationQuery {
|
|
|
938
938
|
async detach(ids) {
|
|
939
939
|
const parentId = this.parent.get(this.relation.parentKey);
|
|
940
940
|
if (ids === undefined) {
|
|
941
|
-
await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
|
|
941
|
+
await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
|
|
942
942
|
return;
|
|
943
943
|
}
|
|
944
944
|
const list = Array.isArray(ids) ? ids : [ids];
|
|
945
945
|
const placeholders = list.map((_, index) => `$${index + 2}`).join(", ");
|
|
946
|
-
await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1 AND ${String(this.relation.relatedPivotKey)} IN (${placeholders})`, [parentId, ...list]);
|
|
946
|
+
await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} IN (${placeholders})`, [parentId, ...list]);
|
|
947
947
|
}
|
|
948
948
|
async sync(ids) {
|
|
949
949
|
await this.detach();
|
|
@@ -1610,7 +1610,7 @@ function hashCastValue(value) {
|
|
|
1610
1610
|
if (isAlreadyHashed(plain)) {
|
|
1611
1611
|
return plain;
|
|
1612
1612
|
}
|
|
1613
|
-
return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost:
|
|
1613
|
+
return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 12 });
|
|
1614
1614
|
}
|
|
1615
1615
|
function hydrateValue(value, cast) {
|
|
1616
1616
|
if (value === null || value === undefined) {
|
package/dist/entries/facades.js
CHANGED
|
@@ -117,6 +117,28 @@ function resolveSmtpConfig() {
|
|
|
117
117
|
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
+
var EMAIL_SHAPE = /^[^\s@\r\n]+@[^\s@\r\n]+\.[^\s@\r\n]+$/u;
|
|
121
|
+
function assertSafeSmtpField(value, field) {
|
|
122
|
+
if (/[\r\n]/u.test(value)) {
|
|
123
|
+
throw new Error(`Mail ${field} must not contain CR or LF.`);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
function smtpEnvelopeAddress(value, field) {
|
|
128
|
+
const trimmed = assertSafeSmtpField(value, field).trim();
|
|
129
|
+
const address = trimmed.includes("<") ? trimmed.match(/<([^>]+)>/u)?.[1] ?? trimmed : trimmed;
|
|
130
|
+
if (!EMAIL_SHAPE.test(address)) {
|
|
131
|
+
throw new Error(`Mail ${field} must be a valid email address.`);
|
|
132
|
+
}
|
|
133
|
+
return address;
|
|
134
|
+
}
|
|
135
|
+
function assertSafeSmtpAddress(value, field) {
|
|
136
|
+
smtpEnvelopeAddress(value, field);
|
|
137
|
+
return assertSafeSmtpField(value, field).trim();
|
|
138
|
+
}
|
|
139
|
+
function redactMailBody(body) {
|
|
140
|
+
return body.replace(/https?:\/\/[^\s]+/giu, "[redacted-url]").replace(/[?&](token|signature|expires|code)=[^&\s]+/giu, "[redacted-query]");
|
|
141
|
+
}
|
|
120
142
|
function encodeBase64(value) {
|
|
121
143
|
return Buffer.from(value, "utf8").toString("base64");
|
|
122
144
|
}
|
|
@@ -181,6 +203,12 @@ async function openSmtpConnection(config) {
|
|
|
181
203
|
return { socket, readResponse };
|
|
182
204
|
}
|
|
183
205
|
async function defaultSmtpTransport(config, message) {
|
|
206
|
+
if (config.username) {
|
|
207
|
+
assertSafeSmtpField(config.username, "username");
|
|
208
|
+
}
|
|
209
|
+
if (config.password) {
|
|
210
|
+
assertSafeSmtpField(config.password, "password");
|
|
211
|
+
}
|
|
184
212
|
const { socket, readResponse } = await openSmtpConnection(config);
|
|
185
213
|
try {
|
|
186
214
|
await waitForSmtpResponse(readResponse, ["220"]);
|
|
@@ -198,16 +226,24 @@ async function defaultSmtpTransport(config, message) {
|
|
|
198
226
|
`);
|
|
199
227
|
await waitForSmtpResponse(readResponse, ["235"]);
|
|
200
228
|
}
|
|
201
|
-
|
|
229
|
+
const fromHeader = assertSafeSmtpAddress(config.from, "from");
|
|
230
|
+
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
231
|
+
const from = smtpEnvelopeAddress(config.from, "from");
|
|
232
|
+
const to = smtpEnvelopeAddress(message.to, "to");
|
|
233
|
+
await socket.write(`MAIL FROM:<${from}>\r
|
|
202
234
|
`);
|
|
203
235
|
await waitForSmtpResponse(readResponse, ["250"]);
|
|
204
|
-
await socket.write(`RCPT TO:<${
|
|
236
|
+
await socket.write(`RCPT TO:<${to}>\r
|
|
205
237
|
`);
|
|
206
238
|
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
207
239
|
await socket.write(`DATA\r
|
|
208
240
|
`);
|
|
209
241
|
await waitForSmtpResponse(readResponse, ["354"]);
|
|
210
|
-
const payload = buildSmtpPayload(
|
|
242
|
+
const payload = buildSmtpPayload(fromHeader, {
|
|
243
|
+
...message,
|
|
244
|
+
to: toHeader,
|
|
245
|
+
subject: assertSafeSmtpField(message.subject, "subject")
|
|
246
|
+
});
|
|
211
247
|
await socket.write(payload);
|
|
212
248
|
await waitForSmtpResponse(readResponse, ["250"]);
|
|
213
249
|
await socket.write(`QUIT\r
|
|
@@ -218,10 +254,13 @@ async function defaultSmtpTransport(config, message) {
|
|
|
218
254
|
}
|
|
219
255
|
}
|
|
220
256
|
function buildSmtpPayload(from, message) {
|
|
257
|
+
const safeFrom = assertSafeSmtpAddress(from, "from");
|
|
258
|
+
const safeTo = assertSafeSmtpAddress(message.to, "to");
|
|
259
|
+
const safeSubject = assertSafeSmtpField(message.subject, "subject");
|
|
221
260
|
const headers = [
|
|
222
|
-
`From: ${
|
|
223
|
-
`To: ${
|
|
224
|
-
`Subject: ${
|
|
261
|
+
`From: ${safeFrom}`,
|
|
262
|
+
`To: ${safeTo}`,
|
|
263
|
+
`Subject: ${safeSubject}`,
|
|
225
264
|
"MIME-Version: 1.0"
|
|
226
265
|
];
|
|
227
266
|
if (message.html) {
|
|
@@ -254,7 +293,7 @@ class LogMailDriver {
|
|
|
254
293
|
channel: "mail",
|
|
255
294
|
to: message.to,
|
|
256
295
|
subject: message.subject,
|
|
257
|
-
body: message.body,
|
|
296
|
+
body: redactMailBody(message.body),
|
|
258
297
|
...message.html ? { htmlBytes: Buffer.byteLength(message.html, "utf8") } : {}
|
|
259
298
|
}));
|
|
260
299
|
}
|
|
@@ -278,6 +317,8 @@ class Mailer {
|
|
|
278
317
|
this.driver = driver;
|
|
279
318
|
}
|
|
280
319
|
send(message) {
|
|
320
|
+
assertSafeSmtpAddress(message.to, "to");
|
|
321
|
+
assertSafeSmtpField(message.subject, "subject");
|
|
281
322
|
return this.driver.send(message);
|
|
282
323
|
}
|
|
283
324
|
}
|
|
@@ -295,9 +336,31 @@ function mailer() {
|
|
|
295
336
|
|
|
296
337
|
// ../../src/core/storage/storage.ts
|
|
297
338
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
298
|
-
import { dirname
|
|
339
|
+
import { dirname } from "path";
|
|
299
340
|
var {S3Client } = globalThis.Bun;
|
|
300
341
|
|
|
342
|
+
// ../../src/core/security/safePath.ts
|
|
343
|
+
import { isAbsolute, resolve, sep } from "path";
|
|
344
|
+
function assertPathUnderRoot(rootDirectory, userPath) {
|
|
345
|
+
const trimmed = userPath.trim();
|
|
346
|
+
if (trimmed.length === 0) {
|
|
347
|
+
throw new Error("Path must not be empty.");
|
|
348
|
+
}
|
|
349
|
+
if (trimmed.includes("\x00")) {
|
|
350
|
+
throw new Error("Path must not contain a null byte.");
|
|
351
|
+
}
|
|
352
|
+
const root = resolve(rootDirectory);
|
|
353
|
+
const candidate = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, trimmed.replace(/^\/+/u, ""));
|
|
354
|
+
if (candidate === root || candidate.startsWith(`${root}${sep}`)) {
|
|
355
|
+
return candidate;
|
|
356
|
+
}
|
|
357
|
+
throw new Error("Path escapes the storage root.");
|
|
358
|
+
}
|
|
359
|
+
function assertUrlPathUnderRoot(rootDirectory, urlPathname) {
|
|
360
|
+
return assertPathUnderRoot(rootDirectory, urlPathname.replace(/^\/+/u, ""));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ../../src/core/storage/storage.ts
|
|
301
364
|
class LocalStorageDriver {
|
|
302
365
|
rootDirectory;
|
|
303
366
|
constructor(rootDirectory) {
|
|
@@ -307,7 +370,7 @@ class LocalStorageDriver {
|
|
|
307
370
|
return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
|
|
308
371
|
}
|
|
309
372
|
resolvePath(path) {
|
|
310
|
-
return
|
|
373
|
+
return assertPathUnderRoot(this.resolveRootDirectory(), path);
|
|
311
374
|
}
|
|
312
375
|
async put(path, contents) {
|
|
313
376
|
const absolutePath = this.resolvePath(path);
|
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/authMiddleware.ts
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
authContext,
|
|
5
|
+
runWithAuthContext
|
|
6
|
+
} from "@getstrata/core/auth/authContext";
|
|
4
7
|
function createAuthMiddleware(auth) {
|
|
5
8
|
return async (request, next) => {
|
|
6
|
-
const
|
|
7
|
-
return await
|
|
8
|
-
|
|
9
|
-
if (user) {
|
|
10
|
-
const headers = new Headers(response.headers);
|
|
11
|
-
headers.set("x-authenticated-user-id", String(user.id));
|
|
12
|
-
return new Response(response.body, {
|
|
13
|
-
status: response.status,
|
|
14
|
-
statusText: response.statusText,
|
|
15
|
-
headers
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
return response;
|
|
9
|
+
const resolved = typeof auth.resolveWithSource === "function" ? await auth.resolveWithSource(request) : { user: await auth.resolve(request), credentialSource: null };
|
|
10
|
+
return await runWithAuthContext(resolved.user, resolved.credentialSource, async () => {
|
|
11
|
+
return await next();
|
|
19
12
|
});
|
|
20
13
|
};
|
|
21
14
|
}
|
|
@@ -19,9 +19,72 @@ function envFlagEnabled(value) {
|
|
|
19
19
|
return value === "true";
|
|
20
20
|
}
|
|
21
21
|
|
|
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
|
+
|
|
22
85
|
// ../../src/core/http/corsMiddleware.ts
|
|
23
86
|
function defaultAllowedOrigins() {
|
|
24
|
-
return
|
|
87
|
+
return appUrl();
|
|
25
88
|
}
|
|
26
89
|
function resolveCorsConfig() {
|
|
27
90
|
return {
|
|
@@ -35,7 +98,8 @@ function resolveCorsConfig() {
|
|
|
35
98
|
"X-Authenticated-User-Id",
|
|
36
99
|
"X-Authenticated-User-Role",
|
|
37
100
|
"If-Match",
|
|
38
|
-
"If-None-Match"
|
|
101
|
+
"If-None-Match",
|
|
102
|
+
"X-CSRF-Token"
|
|
39
103
|
],
|
|
40
104
|
maxAgeSeconds: 86400
|
|
41
105
|
};
|
|
@@ -74,6 +138,9 @@ function buildCorsHeaders(request) {
|
|
|
74
138
|
headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
|
|
75
139
|
headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
|
|
76
140
|
headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
|
|
141
|
+
if (allowOrigin !== "*") {
|
|
142
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
143
|
+
}
|
|
77
144
|
return headers;
|
|
78
145
|
}
|
|
79
146
|
export {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/csrfMiddleware.ts
|
|
3
|
+
import { currentCredentialSource } from "@getstrata/core/auth/authContext";
|
|
3
4
|
import { ForbiddenError } from "@getstrata/core/errors/http";
|
|
4
5
|
|
|
5
6
|
// ../../src/core/http/csrfToken.ts
|
|
@@ -149,7 +150,7 @@ function csrfCookieName() {
|
|
|
149
150
|
return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
|
|
150
151
|
}
|
|
151
152
|
function resolveCsrfSecret() {
|
|
152
|
-
return requireConfiguredSecret(["SESSION_SECRET", "
|
|
153
|
+
return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
|
|
153
154
|
}
|
|
154
155
|
function csrfVerifyOptions() {
|
|
155
156
|
return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
|
|
@@ -167,7 +168,7 @@ function createCsrfTokenCookie() {
|
|
|
167
168
|
const secure = isProductionEnv() ? "; Secure" : "";
|
|
168
169
|
return {
|
|
169
170
|
token,
|
|
170
|
-
cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
|
|
171
|
+
cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
|
|
171
172
|
};
|
|
172
173
|
}
|
|
173
174
|
function resolveCsrfToken(request) {
|
|
@@ -224,50 +225,22 @@ function resolveCsrfTokenForRequest(request) {
|
|
|
224
225
|
return resolveCsrfToken(request).token;
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
// ../../src/core/http/
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
function requestUsesHeaderCredentials(request) {
|
|
234
|
-
const scheme = authorizationScheme(request);
|
|
235
|
-
return scheme === "bearer" || scheme === "basic";
|
|
236
|
-
}
|
|
237
|
-
function readBearerToken(request) {
|
|
238
|
-
const header = request.headers.get("authorization")?.trim() ?? "";
|
|
239
|
-
if (!header.toLowerCase().startsWith("bearer ")) {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
const token = header.slice("Bearer ".length).trim();
|
|
243
|
-
return token.length > 0 ? token : null;
|
|
244
|
-
}
|
|
245
|
-
function readBasicCredentials(request) {
|
|
246
|
-
const header = request.headers.get("authorization")?.trim() ?? "";
|
|
247
|
-
if (!header.toLowerCase().startsWith("basic ")) {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
const encoded = header.slice("Basic ".length).trim();
|
|
251
|
-
if (!encoded) {
|
|
252
|
-
return null;
|
|
228
|
+
// ../../src/core/http/csrfMiddleware.ts
|
|
229
|
+
var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
230
|
+
function samlAcsPathname() {
|
|
231
|
+
const configured = process.env.SAML_ACS_URL?.trim();
|
|
232
|
+
if (!configured) {
|
|
233
|
+
return "/auth/saml/acs";
|
|
253
234
|
}
|
|
254
235
|
try {
|
|
255
|
-
|
|
256
|
-
const separator = decoded.indexOf(":");
|
|
257
|
-
if (separator < 0) {
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
return {
|
|
261
|
-
username: decoded.slice(0, separator),
|
|
262
|
-
password: decoded.slice(separator + 1)
|
|
263
|
-
};
|
|
236
|
+
return new URL(configured, "http://strata.invalid").pathname;
|
|
264
237
|
} catch {
|
|
265
|
-
return
|
|
238
|
+
return "/auth/saml/acs";
|
|
266
239
|
}
|
|
267
240
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
241
|
+
function skipsCsrfPath(pathname) {
|
|
242
|
+
return pathname === samlAcsPathname() || pathname.startsWith("/scim/");
|
|
243
|
+
}
|
|
271
244
|
function appendSetCookie(response, cookie) {
|
|
272
245
|
const headers = new Headers(response.headers);
|
|
273
246
|
headers.append("set-cookie", cookie);
|
|
@@ -279,13 +252,18 @@ function appendSetCookie(response, cookie) {
|
|
|
279
252
|
}
|
|
280
253
|
function createCsrfMiddleware() {
|
|
281
254
|
return async (request, next) => {
|
|
282
|
-
|
|
255
|
+
const credentialSource = currentCredentialSource();
|
|
256
|
+
if (credentialSource === "bearer" || credentialSource === "basic") {
|
|
257
|
+
return await next();
|
|
258
|
+
}
|
|
259
|
+
const pathname = new URL(request.url).pathname;
|
|
260
|
+
if (skipsCsrfPath(pathname)) {
|
|
283
261
|
return await next();
|
|
284
262
|
}
|
|
285
263
|
const method = request.method.toUpperCase();
|
|
286
264
|
if (!MUTATING_METHODS.has(method)) {
|
|
287
|
-
const csrf = resolveCsrfToken(request);
|
|
288
265
|
const meta = currentRequestMeta();
|
|
266
|
+
const csrf = meta.csrfToken ? { token: meta.csrfToken } : resolveCsrfToken(request);
|
|
289
267
|
meta.csrfToken = csrf.token;
|
|
290
268
|
const response = await next();
|
|
291
269
|
if (!csrf.cookie) {
|
|
@@ -297,6 +275,18 @@ function createCsrfMiddleware() {
|
|
|
297
275
|
if (!verifyCsrfToken(request, submitted)) {
|
|
298
276
|
throw new ForbiddenError("Invalid or missing CSRF token.");
|
|
299
277
|
}
|
|
278
|
+
const originHeader = request.headers.get("origin");
|
|
279
|
+
let headerOrigin = null;
|
|
280
|
+
if (originHeader) {
|
|
281
|
+
try {
|
|
282
|
+
headerOrigin = new URL(originHeader).origin;
|
|
283
|
+
} catch {
|
|
284
|
+
headerOrigin = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!headerOrigin || headerOrigin !== new URL(request.url).origin) {
|
|
288
|
+
throw new ForbiddenError("Cross-site cookie requests are not allowed.");
|
|
289
|
+
}
|
|
300
290
|
return await next();
|
|
301
291
|
};
|
|
302
292
|
}
|
|
@@ -146,7 +146,7 @@ function csrfCookieName() {
|
|
|
146
146
|
return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
|
|
147
147
|
}
|
|
148
148
|
function resolveCsrfSecret() {
|
|
149
|
-
return requireConfiguredSecret(["SESSION_SECRET", "
|
|
149
|
+
return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
|
|
150
150
|
}
|
|
151
151
|
function csrfVerifyOptions() {
|
|
152
152
|
return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
|
|
@@ -164,7 +164,7 @@ function createCsrfTokenCookie() {
|
|
|
164
164
|
const secure = isProductionEnv() ? "; Secure" : "";
|
|
165
165
|
return {
|
|
166
166
|
token,
|
|
167
|
-
cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
|
|
167
|
+
cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
function resolveCsrfToken(request) {
|
|
@@ -155,10 +155,12 @@ function createFlashCookie(message) {
|
|
|
155
155
|
const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
|
|
156
156
|
const issuedAt = Date.now();
|
|
157
157
|
const value = signFlashPayload(payload, issuedAt);
|
|
158
|
-
|
|
158
|
+
const secure = isProductionEnv() ? "; Secure" : "";
|
|
159
|
+
return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60${secure}`;
|
|
159
160
|
}
|
|
160
161
|
function clearFlashCookie() {
|
|
161
|
-
|
|
162
|
+
const secure = isProductionEnv() ? "; Secure" : "";
|
|
163
|
+
return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
|
162
164
|
}
|
|
163
165
|
function pullFlash(request) {
|
|
164
166
|
const cookieValue = readFlashCookie(request);
|
|
@@ -155,10 +155,12 @@ function createFlashCookie(message) {
|
|
|
155
155
|
const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
|
|
156
156
|
const issuedAt = Date.now();
|
|
157
157
|
const value = signFlashPayload(payload, issuedAt);
|
|
158
|
-
|
|
158
|
+
const secure = isProductionEnv() ? "; Secure" : "";
|
|
159
|
+
return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60${secure}`;
|
|
159
160
|
}
|
|
160
161
|
function clearFlashCookie() {
|
|
161
|
-
|
|
162
|
+
const secure = isProductionEnv() ? "; Secure" : "";
|
|
163
|
+
return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
|
|
162
164
|
}
|
|
163
165
|
function pullFlash(request) {
|
|
164
166
|
const cookieValue = readFlashCookie(request);
|
|
@@ -5,7 +5,7 @@ import { ForbiddenError } from "@getstrata/core/errors/http";
|
|
|
5
5
|
// ../../src/core/http/validation.ts
|
|
6
6
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
7
7
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
8
|
-
import {
|
|
8
|
+
import { currentTenant } from "@getstrata/core/tenant/tenantContext";
|
|
9
9
|
function buildRequestCacheKey(fallbackPath, request) {
|
|
10
10
|
if (!request) {
|
|
11
11
|
return fallbackPath;
|
|
@@ -13,7 +13,7 @@ function buildRequestCacheKey(fallbackPath, request) {
|
|
|
13
13
|
const url = new URL(request.url);
|
|
14
14
|
const user = currentAuthUser();
|
|
15
15
|
const authScope = user ? `u:${user.id}` : "guest";
|
|
16
|
-
const tenantScope = `t:${
|
|
16
|
+
const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
|
|
17
17
|
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
18
18
|
}
|
|
19
19
|
function getQueryParams(request) {
|