@open-mercato/shared 0.6.6-develop.6431.1.g1037269810 → 0.6.6-develop.6450.1.b493fb430c
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/dist/lib/commands/scope.js +14 -1
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/crud/exporters.js +14 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/ratelimit/helpers.js +4 -1
- package/dist/lib/ratelimit/helpers.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/scope.test.ts +69 -2
- package/src/lib/commands/scope.ts +15 -1
- package/src/lib/crud/__tests__/exporters.test.ts +31 -0
- package/src/lib/crud/exporters.ts +16 -1
- package/src/lib/ratelimit/__tests__/helpers.test.ts +2 -2
- package/src/lib/ratelimit/helpers.ts +7 -3
|
@@ -83,6 +83,11 @@ function ensureOrganizationScope(ctx, organizationId) {
|
|
|
83
83
|
}
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
|
+
if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {
|
|
87
|
+
const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
88
|
+
logScopeViolation(ctx, organizationId, currentOrg2);
|
|
89
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
90
|
+
}
|
|
86
91
|
if (isOrganizationAccessAllowed({
|
|
87
92
|
isSuperAdmin,
|
|
88
93
|
allowedOrganizationIds: scope.allowedIds,
|
|
@@ -95,8 +100,16 @@ function ensureOrganizationScope(ctx, organizationId) {
|
|
|
95
100
|
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
96
101
|
}
|
|
97
102
|
function ensureTenantScope(ctx, tenantId) {
|
|
103
|
+
const isSuperAdmin = ctx.auth?.isSuperAdmin === true;
|
|
98
104
|
const currentTenant = ctx.auth?.tenantId ?? null;
|
|
99
|
-
if (currentTenant
|
|
105
|
+
if (!currentTenant) {
|
|
106
|
+
if (tenantId && ctx.auth && !isSuperAdmin) {
|
|
107
|
+
logTenantScopeViolation(ctx, tenantId, currentTenant);
|
|
108
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (currentTenant !== tenantId) {
|
|
100
113
|
logTenantScopeViolation(ctx, tenantId, currentTenant);
|
|
101
114
|
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
102
115
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/commands/scope.ts"],
|
|
4
|
-
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const currentTenant = ctx.auth?.tenantId ?? null\n if (!currentTenant) {\n if (tenantId && ctx.auth && !isSuperAdmin) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n if (currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["currentOrg"]
|
|
7
7
|
}
|
|
@@ -18,6 +18,19 @@ function escapeCsv(value) {
|
|
|
18
18
|
}
|
|
19
19
|
return value;
|
|
20
20
|
}
|
|
21
|
+
function neutralizeSpreadsheetFormula(value) {
|
|
22
|
+
if (/^[=+\-@\t\r\n]/.test(value)) {
|
|
23
|
+
return `'${value}`;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function normalizeCsvValue(value) {
|
|
28
|
+
const normalized = normalizeValue(value);
|
|
29
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
return neutralizeSpreadsheetFormula(normalized);
|
|
33
|
+
}
|
|
21
34
|
function escapeMarkdown(value) {
|
|
22
35
|
const escaped = value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br />");
|
|
23
36
|
return escaped || " ";
|
|
@@ -38,7 +51,7 @@ function serializeCsv(prepared) {
|
|
|
38
51
|
const lines = [headers.join(",")];
|
|
39
52
|
for (const row of prepared.rows) {
|
|
40
53
|
const values = prepared.columns.map((col) => {
|
|
41
|
-
const raw =
|
|
54
|
+
const raw = normalizeCsvValue(row[col.field]);
|
|
42
55
|
return escapeCsv(raw);
|
|
43
56
|
});
|
|
44
57
|
lines.push(values.join(","));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/exporters.ts"],
|
|
4
|
-
"sourcesContent": ["export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nexport type CrudExportColumn = {\n field: string\n header: string\n}\n\nexport type PreparedExport = {\n columns: CrudExportColumn[]\n rows: Array<Record<string, unknown>>\n}\n\nexport type SerializedExport = {\n body: string\n contentType: string\n fileExtension: string\n}\n\nconst CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'\nconst JSON_CONTENT_TYPE = 'application/json; charset=utf-8'\nconst XML_CONTENT_TYPE = 'application/xml; charset=utf-8'\nconst MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8'\n\nfunction normalizeValue(value: unknown): string {\n if (value == null) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'bigint') return value.toString()\n if (typeof value === 'object') {\n if (Array.isArray(value)) return value.map((v) => normalizeValue(v)).filter(Boolean).join(', ')\n return JSON.stringify(value)\n }\n return String(value)\n}\n\nfunction escapeCsv(value: string): string {\n if (/[\",\\n\\r]/.test(value)) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n }\n return value\n}\n\nfunction escapeMarkdown(value: string): string {\n const escaped = value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\\|/g, '\\\\|')\n .replace(/\\r?\\n/g, '<br />')\n return escaped || ' '\n}\n\nfunction escapeXmlTag(tag: string, fallbackIndex: number): string {\n const sanitized = tag.replace(/[^A-Za-z0-9_:-]/g, '_')\n const normalized = sanitized.length > 0 ? sanitized : `field_${fallbackIndex}`\n if (/^[^A-Za-z_]/.test(normalized)) {\n return `f_${normalized}`\n }\n return normalized\n}\n\nfunction escapeXmlValue(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\nfunction serializeCsv(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => col.header)\n const lines = [headers.join(',')]\n for (const row of prepared.rows) {\n const values = prepared.columns.map((col) => {\n const raw =
|
|
5
|
-
"mappings": "AAkBA,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;AAC1B,MAAM,mBAAmB;AACzB,MAAM,wBAAwB;AAE9B,SAAS,eAAe,OAAwB;AAC9C,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI,OAAO,UAAU,SAAU,QAAO,MAAM,SAAS;AACrD,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,MAAM,IAAI,CAAC,MAAM,eAAe,CAAC,CAAC,EAAE,OAAO,OAAO,EAAE,KAAK,IAAI;AAC9F,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B;AACA,SAAO,OAAO,KAAK;AACrB;AAEA,SAAS,UAAU,OAAuB;AACxC,MAAI,WAAW,KAAK,KAAK,GAAG;AAC1B,WAAO,IAAI,MAAM,QAAQ,MAAM,IAAI,CAAC;AAAA,EACtC;AACA,SAAO;AACT;AAEA,SAAS,eAAe,OAAuB;AAC7C,QAAM,UAAU,MACb,QAAQ,OAAO,MAAM,EACrB,QAAQ,OAAO,KAAK,EACpB,QAAQ,UAAU,QAAQ;AAC7B,SAAO,WAAW;AACpB;AAEA,SAAS,aAAa,KAAa,eAA+B;AAChE,QAAM,YAAY,IAAI,QAAQ,oBAAoB,GAAG;AACrD,QAAM,aAAa,UAAU,SAAS,IAAI,YAAY,SAAS,aAAa;AAC5E,MAAI,cAAc,KAAK,UAAU,GAAG;AAClC,WAAO,KAAK,UAAU;AAAA,EACxB;AACA,SAAO;AACT;AAEA,SAAS,eAAe,OAAuB;AAC7C,SAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,QAAQ;AAC3B;AAEA,SAAS,aAAa,UAA4C;AAChE,QAAM,UAAU,SAAS,QAAQ,IAAI,CAAC,QAAQ,IAAI,MAAM;AACxD,QAAM,QAAQ,CAAC,QAAQ,KAAK,GAAG,CAAC;AAChC,aAAW,OAAO,SAAS,MAAM;AAC/B,UAAM,SAAS,SAAS,QAAQ,IAAI,CAAC,QAAQ;AAC3C,YAAM,MAAM,
|
|
4
|
+
"sourcesContent": ["export type CrudExportFormat = 'csv' | 'json' | 'xml' | 'markdown'\n\nexport type CrudExportColumn = {\n field: string\n header: string\n}\n\nexport type PreparedExport = {\n columns: CrudExportColumn[]\n rows: Array<Record<string, unknown>>\n}\n\nexport type SerializedExport = {\n body: string\n contentType: string\n fileExtension: string\n}\n\nconst CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'\nconst JSON_CONTENT_TYPE = 'application/json; charset=utf-8'\nconst XML_CONTENT_TYPE = 'application/xml; charset=utf-8'\nconst MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8'\n\nfunction normalizeValue(value: unknown): string {\n if (value == null) return ''\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'bigint') return value.toString()\n if (typeof value === 'object') {\n if (Array.isArray(value)) return value.map((v) => normalizeValue(v)).filter(Boolean).join(', ')\n return JSON.stringify(value)\n }\n return String(value)\n}\n\nfunction escapeCsv(value: string): string {\n if (/[\",\\n\\r]/.test(value)) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`\n }\n return value\n}\n\nfunction neutralizeSpreadsheetFormula(value: string): string {\n if (/^[=+\\-@\\t\\r\\n]/.test(value)) {\n return `'${value}`\n }\n return value\n}\n\nfunction normalizeCsvValue(value: unknown): string {\n const normalized = normalizeValue(value)\n if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {\n return normalized\n }\n return neutralizeSpreadsheetFormula(normalized)\n}\n\nfunction escapeMarkdown(value: string): string {\n const escaped = value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\\|/g, '\\\\|')\n .replace(/\\r?\\n/g, '<br />')\n return escaped || ' '\n}\n\nfunction escapeXmlTag(tag: string, fallbackIndex: number): string {\n const sanitized = tag.replace(/[^A-Za-z0-9_:-]/g, '_')\n const normalized = sanitized.length > 0 ? sanitized : `field_${fallbackIndex}`\n if (/^[^A-Za-z_]/.test(normalized)) {\n return `f_${normalized}`\n }\n return normalized\n}\n\nfunction escapeXmlValue(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\nfunction serializeCsv(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => col.header)\n const lines = [headers.join(',')]\n for (const row of prepared.rows) {\n const values = prepared.columns.map((col) => {\n const raw = normalizeCsvValue(row[col.field])\n return escapeCsv(raw)\n })\n lines.push(values.join(','))\n }\n return {\n body: lines.join('\\n'),\n contentType: CSV_CONTENT_TYPE,\n fileExtension: 'csv',\n }\n}\n\nfunction serializeJson(prepared: PreparedExport): SerializedExport {\n // For JSON we return objects keyed by exported field names with human headers as metadata\n const payload = prepared.rows.map((row) => {\n const obj: Record<string, unknown> = {}\n for (const column of prepared.columns) {\n obj[column.header] = row[column.field]\n }\n return obj\n })\n return {\n body: JSON.stringify(payload, null, 2),\n contentType: JSON_CONTENT_TYPE,\n fileExtension: 'json',\n }\n}\n\nfunction serializeXml(prepared: PreparedExport): SerializedExport {\n const lines: string[] = ['<?xml version=\"1.0\" encoding=\"UTF-8\"?>', '<records>']\n for (const row of prepared.rows) {\n lines.push(' <record>')\n prepared.columns.forEach((column, index) => {\n const tag = escapeXmlTag(column.field || column.header, index)\n const value = escapeXmlValue(normalizeValue(row[column.field]))\n lines.push(` <${tag}>${value}</${tag}>`)\n })\n lines.push(' </record>')\n }\n lines.push('</records>')\n return {\n body: lines.join('\\n'),\n contentType: XML_CONTENT_TYPE,\n fileExtension: 'xml',\n }\n}\n\nfunction serializeMarkdown(prepared: PreparedExport): SerializedExport {\n const headers = prepared.columns.map((col) => escapeMarkdown(col.header))\n const headerLine = `| ${headers.join(' | ')} |`\n const dividerLine = `| ${prepared.columns.map(() => '---').join(' | ')} |`\n const rows = prepared.rows.map((row) => {\n const cells = prepared.columns.map((col) => escapeMarkdown(normalizeValue(row[col.field])))\n return `| ${cells.join(' | ')} |`\n })\n const body = [headerLine, dividerLine, ...rows].join('\\n')\n return {\n body,\n contentType: MARKDOWN_CONTENT_TYPE,\n fileExtension: 'md',\n }\n}\n\nexport function serializeExport(prepared: PreparedExport, format: CrudExportFormat): SerializedExport {\n switch (format) {\n case 'csv':\n return serializeCsv(prepared)\n case 'json':\n return serializeJson(prepared)\n case 'xml':\n return serializeXml(prepared)\n case 'markdown':\n return serializeMarkdown(prepared)\n default:\n return serializeJson(prepared)\n }\n}\n\nexport function normalizeExportFormat(raw: unknown): CrudExportFormat | null {\n if (typeof raw !== 'string' || raw.trim() === '') return null\n const value = raw.toLowerCase()\n if (value === 'csv') return 'csv'\n if (value === 'json' || value === 'application/json') return 'json'\n if (value === 'xml' || value === 'application/xml') return 'xml'\n if (value === 'markdown' || value === 'md' || value === 'text/markdown') return 'markdown'\n return null\n}\n\nexport function defaultExportFilename(base: string | undefined | null, format: CrudExportFormat): string {\n const safeBase = (base && base.trim().length > 0 ? base.trim() : 'export')\n .replace(/[^a-z0-9_\\-]/gi, '_')\n const suffix = format === 'markdown' ? 'md' : format\n return `${safeBase}.${suffix}`\n}\n\nexport function toHeaderLabel(key: string): string {\n const withoutPrefix = key.replace(/^cf[:_\\-\\s]+/i, '')\n const normalized = withoutPrefix.replace(/[_\\-\\s]+/g, ' ').trim()\n if (!normalized) return 'Field'\n return normalized.replace(/\\b\\w/g, (char) => char.toUpperCase())\n}\n\nexport function ensureColumns(rows: Array<Record<string, unknown>>, hint?: CrudExportColumn[]): CrudExportColumn[] {\n if (hint && hint.length > 0) return hint\n const used = new Map<string, string>()\n rows.forEach((row) => {\n Object.keys(row || {}).forEach((key) => {\n if (!used.has(key)) used.set(key, key)\n })\n })\n if (used.size === 0) return [{ field: 'id', header: 'ID' }]\n return Array.from(used.keys()).map((key) => ({\n field: key,\n header: toHeaderLabel(key),\n }))\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -26,7 +26,10 @@ async function checkRateLimit(rateLimiterService, config, key, errorMessage) {
|
|
|
26
26
|
}
|
|
27
27
|
function getClientIp(req, trustProxyDepth = 0) {
|
|
28
28
|
const forwarded = req.headers.get("x-forwarded-for");
|
|
29
|
-
if (
|
|
29
|
+
if (trustProxyDepth <= 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (forwarded) {
|
|
30
33
|
const ips = forwarded.split(",").map((ip) => ip.trim());
|
|
31
34
|
const clientIndex = ips.length - trustProxyDepth;
|
|
32
35
|
return clientIndex >= 0 ? ips[clientIndex] : ips[0];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/helpers.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] : ips[0]\n }\n return req.headers.get('x-real-ip') ?? null\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,mBAAmB,GAAG;AACxB,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,IAAI,IAAI,CAAC;AAAA,EACpD;AACA,SAAO,IAAI,QAAQ,IAAI,WAAW,KAAK;AACzC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6450.1.b493fb430c'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6450.1.b493fb430c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@mikro-orm/core": "^7.1.5",
|
|
94
94
|
"@mikro-orm/decorators": "^7.1.5",
|
|
95
95
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
96
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
96
|
+
"@open-mercato/cache": "0.6.6-develop.6450.1.b493fb430c",
|
|
97
97
|
"@types/sanitize-html": "^2.16.1",
|
|
98
98
|
"dotenv": "^17.4.2",
|
|
99
99
|
"rate-limiter-flexible": "^11.2.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'
|
|
1
|
+
import { ensureOrganizationScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
|
|
2
2
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
4
4
|
|
|
@@ -6,21 +6,24 @@ type ScopeShape = NonNullable<CommandRuntimeContext['organizationScope']>
|
|
|
6
6
|
|
|
7
7
|
function buildCtx(overrides: {
|
|
8
8
|
isSuperAdmin?: boolean
|
|
9
|
+
tenantId?: string | null
|
|
9
10
|
orgId?: string | null
|
|
10
11
|
selectedOrganizationId?: string | null
|
|
11
12
|
organizationScope?: ScopeShape | null
|
|
13
|
+
systemActor?: boolean
|
|
12
14
|
}): CommandRuntimeContext {
|
|
13
15
|
return {
|
|
14
16
|
container: {} as CommandRuntimeContext['container'],
|
|
15
17
|
auth: {
|
|
16
18
|
sub: 'user-1',
|
|
17
|
-
tenantId: 'tenant-1',
|
|
19
|
+
tenantId: overrides.tenantId === undefined ? 'tenant-1' : overrides.tenantId,
|
|
18
20
|
orgId: overrides.orgId ?? null,
|
|
19
21
|
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
|
20
22
|
},
|
|
21
23
|
organizationScope: overrides.organizationScope ?? null,
|
|
22
24
|
selectedOrganizationId: overrides.selectedOrganizationId ?? null,
|
|
23
25
|
organizationIds: null,
|
|
26
|
+
systemActor: overrides.systemActor,
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -34,6 +37,33 @@ function buildScope(overrides: Partial<ScopeShape>): ScopeShape {
|
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
describe('ensureTenantScope', () => {
|
|
41
|
+
it('denies a tenant-less non-superadmin acting on a tenant-scoped target (#3910)', () => {
|
|
42
|
+
const ctx = buildCtx({ tenantId: null, isSuperAdmin: false })
|
|
43
|
+
expect(() => ensureTenantScope(ctx, 'tenant-2')).toThrow(CrudHttpError)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('allows super admins with no tenant context to act on tenant-scoped targets', () => {
|
|
47
|
+
const ctx = buildCtx({ tenantId: null, isSuperAdmin: true })
|
|
48
|
+
expect(() => ensureTenantScope(ctx, 'tenant-2')).not.toThrow()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves legacy system contexts without an authenticated actor', () => {
|
|
52
|
+
const ctx = { ...buildCtx({}), auth: null }
|
|
53
|
+
expect(() => ensureTenantScope(ctx, 'tenant-2')).not.toThrow()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('denies authenticated tenant-less systemActor contexts unless they are superadmin (#3910)', () => {
|
|
57
|
+
const ctx = buildCtx({ tenantId: null, isSuperAdmin: false, systemActor: true })
|
|
58
|
+
expect(() => ensureTenantScope(ctx, 'tenant-2')).toThrow(CrudHttpError)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('allows a scoped principal acting inside its own tenant', () => {
|
|
62
|
+
const ctx = buildCtx({ tenantId: 'tenant-1' })
|
|
63
|
+
expect(() => ensureTenantScope(ctx, 'tenant-1')).not.toThrow()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
37
67
|
describe('ensureOrganizationScope', () => {
|
|
38
68
|
it('denies a restricted floating user acting on an org outside allowedIds (#2239)', () => {
|
|
39
69
|
const ctx = buildCtx({
|
|
@@ -59,6 +89,43 @@ describe('ensureOrganizationScope', () => {
|
|
|
59
89
|
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
60
90
|
})
|
|
61
91
|
|
|
92
|
+
it('denies null-tenant organization scope for a non-superadmin (#3910)', () => {
|
|
93
|
+
const ctx = buildCtx({
|
|
94
|
+
tenantId: null,
|
|
95
|
+
organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
|
|
96
|
+
})
|
|
97
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('allows null-tenant organization scope for a superadmin', () => {
|
|
101
|
+
const ctx = buildCtx({
|
|
102
|
+
tenantId: null,
|
|
103
|
+
isSuperAdmin: true,
|
|
104
|
+
organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
|
|
105
|
+
})
|
|
106
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('preserves null-tenant organization scope for system contexts without an authenticated actor', () => {
|
|
110
|
+
const ctx = {
|
|
111
|
+
...buildCtx({
|
|
112
|
+
tenantId: null,
|
|
113
|
+
organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
|
|
114
|
+
}),
|
|
115
|
+
auth: null,
|
|
116
|
+
}
|
|
117
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('denies authenticated null-tenant systemActor organization scopes unless they are superadmin (#3910)', () => {
|
|
121
|
+
const ctx = buildCtx({
|
|
122
|
+
tenantId: null,
|
|
123
|
+
systemActor: true,
|
|
124
|
+
organizationScope: buildScope({ tenantId: null, allowedIds: null, filterIds: null }),
|
|
125
|
+
})
|
|
126
|
+
expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
|
|
127
|
+
})
|
|
128
|
+
|
|
62
129
|
it('allows a restricted user acting on an org inside allowedIds (allow-path regression)', () => {
|
|
63
130
|
const ctx = buildCtx({
|
|
64
131
|
orgId: 'org-a',
|
|
@@ -122,6 +122,12 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
|
|
|
122
122
|
return
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
if (!scope.tenantId && organizationId && ctx.auth && !isSuperAdmin) {
|
|
126
|
+
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
127
|
+
logScopeViolation(ctx, organizationId, currentOrg)
|
|
128
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
if (
|
|
126
132
|
isOrganizationAccessAllowed({
|
|
127
133
|
isSuperAdmin,
|
|
@@ -138,8 +144,16 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
|
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
export function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {
|
|
147
|
+
const isSuperAdmin = ctx.auth?.isSuperAdmin === true
|
|
141
148
|
const currentTenant = ctx.auth?.tenantId ?? null
|
|
142
|
-
if (currentTenant
|
|
149
|
+
if (!currentTenant) {
|
|
150
|
+
if (tenantId && ctx.auth && !isSuperAdmin) {
|
|
151
|
+
logTenantScopeViolation(ctx, tenantId, currentTenant)
|
|
152
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
153
|
+
}
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (currentTenant !== tenantId) {
|
|
143
157
|
logTenantScopeViolation(ctx, tenantId, currentTenant)
|
|
144
158
|
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
145
159
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { serializeExport } from '../exporters'
|
|
2
|
+
|
|
3
|
+
describe('CRUD export serializers', () => {
|
|
4
|
+
it('neutralizes spreadsheet formula prefixes in CSV string cells', () => {
|
|
5
|
+
const serialized = serializeExport({
|
|
6
|
+
columns: [
|
|
7
|
+
{ field: 'name', header: 'Name' },
|
|
8
|
+
{ field: 'note', header: 'Note' },
|
|
9
|
+
{ field: 'balance', header: 'Balance' },
|
|
10
|
+
],
|
|
11
|
+
rows: [
|
|
12
|
+
{
|
|
13
|
+
name: "=cmd|'/c calc'!A1",
|
|
14
|
+
note: '@SUM(A1:A2)',
|
|
15
|
+
balance: -42,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: '\t=HYPERLINK("https://example.invalid")',
|
|
19
|
+
note: '\r+1+1',
|
|
20
|
+
balance: '-7',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
}, 'csv')
|
|
24
|
+
|
|
25
|
+
expect(serialized.body.split('\n')).toEqual([
|
|
26
|
+
'Name,Note,Balance',
|
|
27
|
+
"'=cmd|'/c calc'!A1,'@SUM(A1:A2),-42",
|
|
28
|
+
'"\'\t=HYPERLINK(""https://example.invalid"")","\'\r+1+1",\'-7',
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -39,6 +39,21 @@ function escapeCsv(value: string): string {
|
|
|
39
39
|
return value
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function neutralizeSpreadsheetFormula(value: string): string {
|
|
43
|
+
if (/^[=+\-@\t\r\n]/.test(value)) {
|
|
44
|
+
return `'${value}`
|
|
45
|
+
}
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeCsvValue(value: unknown): string {
|
|
50
|
+
const normalized = normalizeValue(value)
|
|
51
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
|
|
52
|
+
return normalized
|
|
53
|
+
}
|
|
54
|
+
return neutralizeSpreadsheetFormula(normalized)
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
function escapeMarkdown(value: string): string {
|
|
43
58
|
const escaped = value
|
|
44
59
|
.replace(/\\/g, '\\\\')
|
|
@@ -70,7 +85,7 @@ function serializeCsv(prepared: PreparedExport): SerializedExport {
|
|
|
70
85
|
const lines = [headers.join(',')]
|
|
71
86
|
for (const row of prepared.rows) {
|
|
72
87
|
const values = prepared.columns.map((col) => {
|
|
73
|
-
const raw =
|
|
88
|
+
const raw = normalizeCsvValue(row[col.field])
|
|
74
89
|
return escapeCsv(raw)
|
|
75
90
|
})
|
|
76
91
|
lines.push(values.join(','))
|
|
@@ -101,14 +101,14 @@ describe('getClientIp', () => {
|
|
|
101
101
|
expect(getClientIp(req)).toBeNull()
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it('
|
|
104
|
+
it('ignores x-real-ip when trustProxyDepth is 0', () => {
|
|
105
105
|
const req = new Request('http://localhost', {
|
|
106
106
|
headers: {
|
|
107
107
|
'x-forwarded-for': 'spoofed-ip',
|
|
108
108
|
'x-real-ip': '172.16.0.5',
|
|
109
109
|
},
|
|
110
110
|
})
|
|
111
|
-
expect(getClientIp(req, 0)).
|
|
111
|
+
expect(getClientIp(req, 0)).toBeNull()
|
|
112
112
|
})
|
|
113
113
|
|
|
114
114
|
it('extracts last IP with trustProxyDepth=1 (single proxy)', () => {
|
|
@@ -45,15 +45,19 @@ export async function checkRateLimit(
|
|
|
45
45
|
* Extract client IP from a request, respecting reverse proxy trust depth.
|
|
46
46
|
*
|
|
47
47
|
* @param trustProxyDepth Number of trusted reverse proxies between the client and the app.
|
|
48
|
-
* - 0 (default): Do not trust
|
|
48
|
+
* - 0 (default): Do not trust proxy-provided IP headers; return null.
|
|
49
49
|
* - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP.
|
|
50
50
|
* - N: N trusted proxies — the Nth-from-last entry is the client IP.
|
|
51
51
|
*
|
|
52
|
-
* With depth=0, X-Forwarded-For
|
|
52
|
+
* With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.
|
|
53
53
|
*/
|
|
54
54
|
export function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {
|
|
55
55
|
const forwarded = req.headers.get('x-forwarded-for')
|
|
56
|
-
if (
|
|
56
|
+
if (trustProxyDepth <= 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (forwarded) {
|
|
57
61
|
const ips = forwarded.split(',').map((ip) => ip.trim())
|
|
58
62
|
const clientIndex = ips.length - trustProxyDepth
|
|
59
63
|
return clientIndex >= 0 ? ips[clientIndex] : ips[0]
|