@open-mercato/storage-s3 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/integration.js +1 -1
- package/dist/modules/storage_s3/integration.js.map +1 -1
- package/dist/modules/storage_s3/lib/endpoint-safety.js +126 -0
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
- package/dist/modules/storage_s3/lib/health.js +18 -1
- package/dist/modules/storage_s3/lib/health.js.map +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js +19 -1
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/package.json +4 -4
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
- package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +11 -0
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/endpoint-safety.ts +169 -0
- package/src/modules/storage_s3/lib/health.ts +18 -0
- package/src/modules/storage_s3/lib/s3-driver.ts +21 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Found
|
|
1
|
+
Found 20 entry points
|
|
2
2
|
storage-s3 built successfully
|
|
@@ -72,7 +72,7 @@ const integration = {
|
|
|
72
72
|
type: "url",
|
|
73
73
|
required: false,
|
|
74
74
|
placeholder: "https://fra1.digitaloceanspaces.com",
|
|
75
|
-
helpText: "Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
75
|
+
helpText: "Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true."
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
key: "forcePathStyle",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/storage_s3/integration.ts"],
|
|
4
|
-
"sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
4
|
+
"sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',\n },\n {\n key: 'forcePathStyle',\n label: 'Force Path Style',\n type: 'boolean',\n required: false,\n helpText: 'Enable path-style addressing. Required for MinIO. Leave disabled for AWS S3 and DigitalOcean Spaces.',\n },\n ],\n },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
|
|
5
5
|
"mappings": "AAEO,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AAAA,EACF,UAAU;AAAA,EACV,KAAK;AAAA,EACL,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM,CAAC,MAAM,OAAO,WAAW,SAAS,qBAAqB;AAAA,EAC7D,aAAa,EAAE,SAAS,gBAAgB;AAAA,EACxC,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,SAAS;AAAA,UACP,EAAE,OAAO,eAAe,OAAO,cAAc;AAAA,UAC7C,EAAE,OAAO,WAAW,OAAO,8DAA8D;AAAA,QAC3F;AAAA,MACF;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { Agent as HttpAgent } from "node:http";
|
|
3
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
6
|
+
import {
|
|
7
|
+
assertSafeOutboundUrl,
|
|
8
|
+
assertStaticallySafeOutboundUrl,
|
|
9
|
+
parseOutboundUrl,
|
|
10
|
+
resolveSafeOutboundUrl
|
|
11
|
+
} from "@open-mercato/shared/lib/url-safety";
|
|
12
|
+
const SUBJECT = "S3 endpoint";
|
|
13
|
+
class UnsafeS3EndpointError extends Error {
|
|
14
|
+
constructor(reason, message) {
|
|
15
|
+
super(message ?? `S3 endpoint rejected: ${reason}`);
|
|
16
|
+
this.name = "UnsafeS3EndpointError";
|
|
17
|
+
this.reason = reason;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const s3EndpointErrorFactory = (reason, message) => new UnsafeS3EndpointError(reason, message);
|
|
21
|
+
function assertStaticallySafeS3Endpoint(rawEndpoint, deps = {}) {
|
|
22
|
+
if (!rawEndpoint) return;
|
|
23
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
24
|
+
assertStaticallySafeOutboundUrl(rawEndpoint, {
|
|
25
|
+
errorFactory: s3EndpointErrorFactory,
|
|
26
|
+
subject: SUBJECT,
|
|
27
|
+
allowPrivate
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function assertSafeS3Endpoint(rawEndpoint, deps = {}) {
|
|
31
|
+
if (!rawEndpoint) return;
|
|
32
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
33
|
+
await assertSafeOutboundUrl(rawEndpoint, {
|
|
34
|
+
errorFactory: s3EndpointErrorFactory,
|
|
35
|
+
subject: SUBJECT,
|
|
36
|
+
allowPrivate,
|
|
37
|
+
lookupHost: deps.lookupHost
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function createSafeS3EndpointLookup(rawEndpoint, deps = {}) {
|
|
41
|
+
if (!rawEndpoint) return void 0;
|
|
42
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled();
|
|
43
|
+
if (allowPrivate) return void 0;
|
|
44
|
+
const { hostname } = parseOutboundUrl(rawEndpoint, {
|
|
45
|
+
errorFactory: s3EndpointErrorFactory,
|
|
46
|
+
subject: SUBJECT
|
|
47
|
+
});
|
|
48
|
+
const endpointHostname = normalizeLookupHostname(hostname);
|
|
49
|
+
return (host, options, callback) => {
|
|
50
|
+
const lookupHostname = normalizeLookupHostname(host);
|
|
51
|
+
if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {
|
|
52
|
+
callback(new UnsafeS3EndpointError(
|
|
53
|
+
"blocked_hostname",
|
|
54
|
+
`S3 endpoint lookup host "${host}" does not match configured host "${endpointHostname}"`
|
|
55
|
+
), "", 0);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {
|
|
59
|
+
errorFactory: s3EndpointErrorFactory,
|
|
60
|
+
subject: SUBJECT,
|
|
61
|
+
lookupHost: deps.lookupHost ?? defaultLookupHost
|
|
62
|
+
}).then(({ addresses }) => {
|
|
63
|
+
if (!addresses || addresses.length === 0) {
|
|
64
|
+
const family = isIP(lookupHostname);
|
|
65
|
+
if (options.all) callback(null, [{ address: lookupHostname, family }]);
|
|
66
|
+
else callback(null, lookupHostname, family);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (options.all) {
|
|
70
|
+
callback(null, addresses.map((record) => ({ address: record.address, family: record.family })));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
74
|
+
}).catch((error) => {
|
|
75
|
+
callback(error instanceof Error ? error : new UnsafeS3EndpointError("dns_resolution_failed"), "", 0);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function normalizeLookupHostname(hostname) {
|
|
80
|
+
let normalized = hostname.trim().toLowerCase();
|
|
81
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
|
82
|
+
normalized = normalized.slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
while (normalized.endsWith(".")) {
|
|
85
|
+
normalized = normalized.slice(0, -1);
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
function isAllowedS3LookupHost(lookupHostname, endpointHostname) {
|
|
90
|
+
if (lookupHostname === endpointHostname) return true;
|
|
91
|
+
if (isIP(endpointHostname)) return false;
|
|
92
|
+
return lookupHostname.endsWith(`.${endpointHostname}`);
|
|
93
|
+
}
|
|
94
|
+
function buildLookupValidationUrl(rawEndpoint, lookupHostname) {
|
|
95
|
+
const { url } = parseOutboundUrl(rawEndpoint, {
|
|
96
|
+
errorFactory: s3EndpointErrorFactory,
|
|
97
|
+
subject: SUBJECT
|
|
98
|
+
});
|
|
99
|
+
const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname;
|
|
100
|
+
const port = url.port ? `:${url.port}` : "";
|
|
101
|
+
return `${url.protocol}//${host}${port}`;
|
|
102
|
+
}
|
|
103
|
+
function createSafeS3RequestHandler(rawEndpoint, deps = {}) {
|
|
104
|
+
const lookup = createSafeS3EndpointLookup(rawEndpoint, deps);
|
|
105
|
+
if (!lookup) return void 0;
|
|
106
|
+
return {
|
|
107
|
+
httpAgent: new HttpAgent({ lookup }),
|
|
108
|
+
httpsAgent: new HttpsAgent({ lookup })
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function isAllowInternalS3EndpointsEnabled() {
|
|
112
|
+
return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false);
|
|
113
|
+
}
|
|
114
|
+
const defaultLookupHost = async (hostname) => {
|
|
115
|
+
const records = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
116
|
+
return records;
|
|
117
|
+
};
|
|
118
|
+
export {
|
|
119
|
+
UnsafeS3EndpointError,
|
|
120
|
+
assertSafeS3Endpoint,
|
|
121
|
+
assertStaticallySafeS3Endpoint,
|
|
122
|
+
createSafeS3EndpointLookup,
|
|
123
|
+
createSafeS3RequestHandler,
|
|
124
|
+
isAllowInternalS3EndpointsEnabled
|
|
125
|
+
};
|
|
126
|
+
//# sourceMappingURL=endpoint-safety.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/storage_s3/lib/endpoint-safety.ts"],
|
|
4
|
+
"sourcesContent": ["import { lookup as dnsLookup } from 'node:dns/promises'\nimport { Agent as HttpAgent } from 'node:http'\nimport { Agent as HttpsAgent } from 'node:https'\nimport { isIP, type LookupFunction } from 'node:net'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n resolveSafeOutboundUrl,\n type HostLookup,\n type ResolvedHostAddress,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\n\nconst SUBJECT = 'S3 endpoint'\n\nexport class UnsafeS3EndpointError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `S3 endpoint rejected: ${reason}`)\n this.name = 'UnsafeS3EndpointError'\n this.reason = reason\n }\n}\n\nconst s3EndpointErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeS3EndpointError(reason, message)\n\nexport type AssertStaticS3EndpointDeps = {\n allowInternal?: boolean\n}\n\nexport function assertStaticallySafeS3Endpoint(\n rawEndpoint: string | null | undefined,\n deps: AssertStaticS3EndpointDeps = {},\n): void {\n if (!rawEndpoint) return\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n assertStaticallySafeOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport type AssertSafeS3EndpointDeps = {\n lookupHost?: HostLookup\n allowInternal?: boolean\n}\n\nexport async function assertSafeS3Endpoint(\n rawEndpoint: string | null | undefined,\n deps: AssertSafeS3EndpointDeps = {},\n): Promise<void> {\n if (!rawEndpoint) return\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n await assertSafeOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n\nexport type SafeS3EndpointLookupDeps = {\n lookupHost?: HostLookup\n allowInternal?: boolean\n}\n\nexport function createSafeS3EndpointLookup(\n rawEndpoint: string | null | undefined,\n deps: SafeS3EndpointLookupDeps = {},\n): LookupFunction | undefined {\n if (!rawEndpoint) return undefined\n const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()\n if (allowPrivate) return undefined\n\n const { hostname } = parseOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n })\n const endpointHostname = normalizeLookupHostname(hostname)\n\n return (host, options, callback) => {\n const lookupHostname = normalizeLookupHostname(host)\n if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {\n callback(new UnsafeS3EndpointError(\n 'blocked_hostname',\n `S3 endpoint lookup host \"${host}\" does not match configured host \"${endpointHostname}\"`,\n ), '', 0)\n return\n }\n\n resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n lookupHost: deps.lookupHost ?? defaultLookupHost,\n }).then(({ addresses }) => {\n if (!addresses || addresses.length === 0) {\n const family = isIP(lookupHostname)\n if (options.all) callback(null, [{ address: lookupHostname, family }] as ResolvedHostAddress[])\n else callback(null, lookupHostname, family)\n return\n }\n if (options.all) {\n callback(null, addresses.map((record) => ({ address: record.address, family: record.family })))\n return\n }\n callback(null, addresses[0].address, addresses[0].family)\n }).catch((error: unknown) => {\n callback(error instanceof Error ? error : new UnsafeS3EndpointError('dns_resolution_failed'), '', 0)\n })\n }\n}\n\nfunction normalizeLookupHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nfunction isAllowedS3LookupHost(lookupHostname: string, endpointHostname: string): boolean {\n if (lookupHostname === endpointHostname) return true\n if (isIP(endpointHostname)) return false\n return lookupHostname.endsWith(`.${endpointHostname}`)\n}\n\nfunction buildLookupValidationUrl(rawEndpoint: string, lookupHostname: string): string {\n const { url } = parseOutboundUrl(rawEndpoint, {\n errorFactory: s3EndpointErrorFactory,\n subject: SUBJECT,\n })\n const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname\n const port = url.port ? `:${url.port}` : ''\n return `${url.protocol}//${host}${port}`\n}\n\nexport type SafeS3RequestHandlerOptions = {\n httpAgent: HttpAgent\n httpsAgent: HttpsAgent\n}\n\nexport function createSafeS3RequestHandler(\n rawEndpoint: string | null | undefined,\n deps: SafeS3EndpointLookupDeps = {},\n): SafeS3RequestHandlerOptions | undefined {\n const lookup = createSafeS3EndpointLookup(rawEndpoint, deps)\n if (!lookup) return undefined\n return {\n httpAgent: new HttpAgent({ lookup }),\n httpsAgent: new HttpsAgent({ lookup }),\n }\n}\n\nexport function isAllowInternalS3EndpointsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false)\n}\n\nconst defaultLookupHost: HostLookup = async (hostname) => {\n const records = await dnsLookup(hostname, { all: true, verbatim: true })\n return records\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { assertSafeS3Endpoint, createSafeS3RequestHandler } from "./endpoint-safety.js";
|
|
2
3
|
const s3HealthCheck = {
|
|
3
4
|
async check(credentials) {
|
|
4
5
|
const cfg = credentials;
|
|
@@ -49,11 +50,27 @@ const s3HealthCheck = {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
try {
|
|
54
|
+
await assertSafeS3Endpoint(cfg.endpoint);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : "Unsafe S3 endpoint";
|
|
57
|
+
const reason = err && typeof err === "object" && "reason" in err ? err.reason : void 0;
|
|
58
|
+
return {
|
|
59
|
+
status: "unhealthy",
|
|
60
|
+
message,
|
|
61
|
+
details: {
|
|
62
|
+
...typeof reason === "string" ? { reason } : {}
|
|
63
|
+
},
|
|
64
|
+
checkedAt
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
|
|
52
68
|
const client = new S3Client({
|
|
53
69
|
region: cfg.region ?? "us-east-1",
|
|
54
70
|
endpoint: cfg.endpoint,
|
|
55
71
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
56
|
-
credentials: credentialConfig
|
|
72
|
+
credentials: credentialConfig,
|
|
73
|
+
...requestHandler ? { requestHandler } : {}
|
|
57
74
|
});
|
|
58
75
|
try {
|
|
59
76
|
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/health.ts"],
|
|
4
|
-
"sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,UAAU,yBAAyB;
|
|
4
|
+
"sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n try {\n await assertSafeS3Endpoint(cfg.endpoint)\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'\n const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined\n return {\n status: 'unhealthy',\n message,\n details: {\n ...(typeof reason === 'string' ? { reason } : {}),\n },\n checkedAt,\n }\n }\n\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n ...(requestHandler ? { requestHandler } : {}),\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
ListObjectsV2Command
|
|
11
11
|
} from "@aws-sdk/client-s3";
|
|
12
12
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
13
|
+
import {
|
|
14
|
+
assertSafeS3Endpoint,
|
|
15
|
+
assertStaticallySafeS3Endpoint,
|
|
16
|
+
createSafeS3RequestHandler
|
|
17
|
+
} from "./endpoint-safety.js";
|
|
13
18
|
import {
|
|
14
19
|
assertS3KeyAddressableByTenantScope,
|
|
15
20
|
assertS3KeyScopedToTenant,
|
|
@@ -41,6 +46,7 @@ class S3StorageDriver {
|
|
|
41
46
|
this.key = "s3";
|
|
42
47
|
const cfg = config;
|
|
43
48
|
this.bucket = cfg.bucket;
|
|
49
|
+
this.endpoint = cfg.endpoint;
|
|
44
50
|
this.pathPrefix = cfg.pathPrefix ?? "";
|
|
45
51
|
this.scope = cfg.organizationId && cfg.tenantId ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId } : null;
|
|
46
52
|
const authMode = cfg.authMode;
|
|
@@ -63,11 +69,14 @@ class S3StorageDriver {
|
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
}
|
|
72
|
+
assertStaticallySafeS3Endpoint(cfg.endpoint);
|
|
73
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
|
|
66
74
|
this.client = new S3Client({
|
|
67
75
|
region: cfg.region ?? "us-east-1",
|
|
68
76
|
endpoint: cfg.endpoint,
|
|
69
77
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
70
|
-
credentials
|
|
78
|
+
credentials,
|
|
79
|
+
...requestHandler ? { requestHandler } : {}
|
|
71
80
|
});
|
|
72
81
|
}
|
|
73
82
|
assertKeyScoped(storagePath, scope) {
|
|
@@ -85,6 +94,7 @@ class S3StorageDriver {
|
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
async store(payload) {
|
|
97
|
+
await this.assertEndpointSafe();
|
|
88
98
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null);
|
|
89
99
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null);
|
|
90
100
|
const safeName = sanitizeFileName(payload.fileName || "file");
|
|
@@ -104,6 +114,7 @@ class S3StorageDriver {
|
|
|
104
114
|
async read(partitionCode, storagePath) {
|
|
105
115
|
this.assertPartitionScoped(partitionCode, storagePath);
|
|
106
116
|
this.assertKeyAddressable(storagePath);
|
|
117
|
+
await this.assertEndpointSafe();
|
|
107
118
|
const response = await this.client.send(
|
|
108
119
|
new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
109
120
|
);
|
|
@@ -117,6 +128,7 @@ class S3StorageDriver {
|
|
|
117
128
|
this.assertPartitionScoped(partitionCode, storagePath);
|
|
118
129
|
this.assertKeyAddressable(storagePath);
|
|
119
130
|
try {
|
|
131
|
+
await this.assertEndpointSafe();
|
|
120
132
|
await this.client.send(
|
|
121
133
|
new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
122
134
|
);
|
|
@@ -144,6 +156,7 @@ class S3StorageDriver {
|
|
|
144
156
|
*/
|
|
145
157
|
async putObject(key, buffer, contentType) {
|
|
146
158
|
this.assertKeyScoped(key);
|
|
159
|
+
await this.assertEndpointSafe();
|
|
147
160
|
await this.client.send(
|
|
148
161
|
new PutObjectCommand({
|
|
149
162
|
Bucket: this.bucket,
|
|
@@ -160,6 +173,7 @@ class S3StorageDriver {
|
|
|
160
173
|
async listObjects(prefix, maxKeys = 100, continuationToken, scope) {
|
|
161
174
|
const activeScope = scope ?? this.scope;
|
|
162
175
|
assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix);
|
|
176
|
+
await this.assertEndpointSafe();
|
|
163
177
|
const response = await this.client.send(
|
|
164
178
|
new ListObjectsV2Command({
|
|
165
179
|
Bucket: this.bucket,
|
|
@@ -188,6 +202,7 @@ class S3StorageDriver {
|
|
|
188
202
|
} else {
|
|
189
203
|
this.assertKeyAddressable(storagePath, scope);
|
|
190
204
|
}
|
|
205
|
+
await this.assertEndpointSafe();
|
|
191
206
|
if (operation === "upload") {
|
|
192
207
|
const command2 = new PutObjectCommand({
|
|
193
208
|
Bucket: this.bucket,
|
|
@@ -199,6 +214,9 @@ class S3StorageDriver {
|
|
|
199
214
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath });
|
|
200
215
|
return getSignedUrl(this.client, command, { expiresIn });
|
|
201
216
|
}
|
|
217
|
+
async assertEndpointSafe() {
|
|
218
|
+
await assertSafeS3Endpoint(this.endpoint);
|
|
219
|
+
}
|
|
202
220
|
}
|
|
203
221
|
export {
|
|
204
222
|
S3StorageDriver
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/s3-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('S3 key is not scoped to the requested partition')\n }\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n scope?: S3TenantScope | null,\n ): Promise<string> {\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,YAAY,UAAU;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AA0BP,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAEA,eAAe,eAAe,QAAgD;AAC5E,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,WAAO,GAAG,QAAQ,CAAC,UAAkB,OAAO,KAAK,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC;AACrG,WAAO,GAAG,OAAO,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC,CAAC;AACrD,WAAO,GAAG,SAAS,MAAM;AAAA,EAC3B,CAAC;AACH;AAEO,MAAM,gBAAyC;AAAA,
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertSafeS3Endpoint,\n assertStaticallySafeS3Endpoint,\n createSafeS3RequestHandler,\n} from './endpoint-safety'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly endpoint?: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.endpoint = cfg.endpoint\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n assertStaticallySafeS3Endpoint(cfg.endpoint)\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n ...(requestHandler ? { requestHandler } : {}),\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('S3 key is not scoped to the requested partition')\n }\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n await this.assertEndpointSafe()\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.assertEndpointSafe()\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.assertEndpointSafe()\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n scope?: S3TenantScope | null,\n ): Promise<string> {\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n await this.assertEndpointSafe()\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n\n private async assertEndpointSafe(): Promise<void> {\n await assertSafeS3Endpoint(this.endpoint)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["command"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6749.1.6b54c56dfe",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@aws-sdk/client-s3": "^3.1085.0",
|
|
71
71
|
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
|
72
|
-
"@open-mercato/core": "0.6.7-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.7-develop.6749.1.6b54c56dfe"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
76
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.7-develop.6749.1.6b54c56dfe",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"esbuild": "^0.28.1",
|
|
82
82
|
"glob": "^13.0.6",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { createSafeS3EndpointLookup, UnsafeS3EndpointError } from '../lib/endpoint-safety'
|
|
4
|
+
|
|
5
|
+
function runLookup(
|
|
6
|
+
lookup: NonNullable<ReturnType<typeof createSafeS3EndpointLookup>>,
|
|
7
|
+
hostname: string,
|
|
8
|
+
): Promise<{ address: string; family: number | undefined }> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
lookup(hostname, {}, (err, address, family) => {
|
|
11
|
+
if (err) {
|
|
12
|
+
reject(err)
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if (typeof address !== 'string') {
|
|
16
|
+
reject(new Error('Expected a single lookup result'))
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
resolve({ address, family })
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('S3 endpoint safety', () => {
|
|
25
|
+
it('validates the DNS result used by the AWS SDK lookup callback', async () => {
|
|
26
|
+
const lookupHost = jest
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
|
29
|
+
.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }])
|
|
30
|
+
|
|
31
|
+
const lookup = createSafeS3EndpointLookup('https://objects.example.com', { lookupHost })
|
|
32
|
+
expect(lookup).toBeDefined()
|
|
33
|
+
|
|
34
|
+
await expect(runLookup(lookup!, 'objects.example.com')).resolves.toEqual({
|
|
35
|
+
address: '93.184.216.34',
|
|
36
|
+
family: 4,
|
|
37
|
+
})
|
|
38
|
+
await expect(runLookup(lookup!, 'objects.example.com')).rejects.toMatchObject({
|
|
39
|
+
reason: 'private_ip_resolved',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('validates virtual-hosted bucket endpoint hosts requested by the AWS SDK', async () => {
|
|
44
|
+
const lookupHost = jest.fn().mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
|
45
|
+
const lookup = createSafeS3EndpointLookup('https://objects.example.com', { lookupHost })
|
|
46
|
+
|
|
47
|
+
await expect(runLookup(lookup!, 'test-bucket.objects.example.com')).resolves.toEqual({
|
|
48
|
+
address: '93.184.216.34',
|
|
49
|
+
family: 4,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(lookupHost).toHaveBeenCalledWith('test-bucket.objects.example.com')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects virtual-hosted bucket endpoint hosts that resolve to private addresses', async () => {
|
|
56
|
+
const lookupHost = jest.fn().mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }])
|
|
57
|
+
const lookup = createSafeS3EndpointLookup('https://objects.example.com', { lookupHost })
|
|
58
|
+
|
|
59
|
+
await expect(runLookup(lookup!, 'test-bucket.objects.example.com')).rejects.toMatchObject({
|
|
60
|
+
reason: 'private_ip_resolved',
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('refuses lookup attempts for hosts other than the configured endpoint host', async () => {
|
|
65
|
+
const lookup = createSafeS3EndpointLookup('https://objects.example.com')
|
|
66
|
+
|
|
67
|
+
await expect(runLookup(lookup!, 'metadata.google.internal')).rejects.toBeInstanceOf(
|
|
68
|
+
UnsafeS3EndpointError,
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const mockSend = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@aws-sdk/client-s3', () => ({
|
|
6
|
+
S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })),
|
|
7
|
+
HeadBucketCommand: jest.fn().mockImplementation((params) => ({ _type: 'HeadBucket', ...params })),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { S3Client } from '@aws-sdk/client-s3'
|
|
11
|
+
import { s3HealthCheck } from '../lib/health'
|
|
12
|
+
|
|
13
|
+
const BASE_CREDENTIALS = {
|
|
14
|
+
bucket: 'test-bucket',
|
|
15
|
+
region: 'eu-central-1',
|
|
16
|
+
authMode: 'access_keys',
|
|
17
|
+
accessKeyId: 'AKID',
|
|
18
|
+
secretAccessKey: 'SECRET',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockSend.mockReset()
|
|
23
|
+
;(S3Client as jest.Mock).mockClear()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('s3HealthCheck', () => {
|
|
27
|
+
it('rejects link-local custom endpoints before probing the bucket', async () => {
|
|
28
|
+
const result = await s3HealthCheck.check({
|
|
29
|
+
...BASE_CREDENTIALS,
|
|
30
|
+
endpoint: 'http://169.254.169.254/latest/meta-data/',
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(result.status).toBe('unhealthy')
|
|
34
|
+
expect(result.message).toMatch(/private or reserved IP range/i)
|
|
35
|
+
expect(S3Client).not.toHaveBeenCalled()
|
|
36
|
+
expect(mockSend).not.toHaveBeenCalled()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -107,6 +107,17 @@ describe('S3StorageDriver', () => {
|
|
|
107
107
|
expect(arg.endpoint).toBe('http://minio:9000')
|
|
108
108
|
expect(arg.forcePathStyle).toBe(true)
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
it('rejects link-local custom endpoints before constructing an S3 client', () => {
|
|
112
|
+
expect(() => {
|
|
113
|
+
new S3StorageDriver({
|
|
114
|
+
...BASE_CONFIG,
|
|
115
|
+
endpoint: 'http://169.254.169.254/latest/meta-data/',
|
|
116
|
+
forcePathStyle: true,
|
|
117
|
+
})
|
|
118
|
+
}).toThrow(/private or reserved IP range/i)
|
|
119
|
+
expect(S3Client).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
110
121
|
})
|
|
111
122
|
|
|
112
123
|
describe('store()', () => {
|
|
@@ -79,7 +79,7 @@ export const integration: IntegrationDefinition = {
|
|
|
79
79
|
type: 'url',
|
|
80
80
|
required: false,
|
|
81
81
|
placeholder: 'https://fra1.digitaloceanspaces.com',
|
|
82
|
-
helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3.
|
|
82
|
+
helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
key: 'forcePathStyle',
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
2
|
+
import { Agent as HttpAgent } from 'node:http'
|
|
3
|
+
import { Agent as HttpsAgent } from 'node:https'
|
|
4
|
+
import { isIP, type LookupFunction } from 'node:net'
|
|
5
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import {
|
|
7
|
+
assertSafeOutboundUrl,
|
|
8
|
+
assertStaticallySafeOutboundUrl,
|
|
9
|
+
parseOutboundUrl,
|
|
10
|
+
resolveSafeOutboundUrl,
|
|
11
|
+
type HostLookup,
|
|
12
|
+
type ResolvedHostAddress,
|
|
13
|
+
type UrlSafetyReason,
|
|
14
|
+
} from '@open-mercato/shared/lib/url-safety'
|
|
15
|
+
|
|
16
|
+
const SUBJECT = 'S3 endpoint'
|
|
17
|
+
|
|
18
|
+
export class UnsafeS3EndpointError extends Error {
|
|
19
|
+
public readonly reason: string
|
|
20
|
+
|
|
21
|
+
constructor(reason: string, message?: string) {
|
|
22
|
+
super(message ?? `S3 endpoint rejected: ${reason}`)
|
|
23
|
+
this.name = 'UnsafeS3EndpointError'
|
|
24
|
+
this.reason = reason
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const s3EndpointErrorFactory = (reason: UrlSafetyReason, message: string) =>
|
|
29
|
+
new UnsafeS3EndpointError(reason, message)
|
|
30
|
+
|
|
31
|
+
export type AssertStaticS3EndpointDeps = {
|
|
32
|
+
allowInternal?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertStaticallySafeS3Endpoint(
|
|
36
|
+
rawEndpoint: string | null | undefined,
|
|
37
|
+
deps: AssertStaticS3EndpointDeps = {},
|
|
38
|
+
): void {
|
|
39
|
+
if (!rawEndpoint) return
|
|
40
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
41
|
+
assertStaticallySafeOutboundUrl(rawEndpoint, {
|
|
42
|
+
errorFactory: s3EndpointErrorFactory,
|
|
43
|
+
subject: SUBJECT,
|
|
44
|
+
allowPrivate,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AssertSafeS3EndpointDeps = {
|
|
49
|
+
lookupHost?: HostLookup
|
|
50
|
+
allowInternal?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertSafeS3Endpoint(
|
|
54
|
+
rawEndpoint: string | null | undefined,
|
|
55
|
+
deps: AssertSafeS3EndpointDeps = {},
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
if (!rawEndpoint) return
|
|
58
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
59
|
+
await assertSafeOutboundUrl(rawEndpoint, {
|
|
60
|
+
errorFactory: s3EndpointErrorFactory,
|
|
61
|
+
subject: SUBJECT,
|
|
62
|
+
allowPrivate,
|
|
63
|
+
lookupHost: deps.lookupHost,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SafeS3EndpointLookupDeps = {
|
|
68
|
+
lookupHost?: HostLookup
|
|
69
|
+
allowInternal?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createSafeS3EndpointLookup(
|
|
73
|
+
rawEndpoint: string | null | undefined,
|
|
74
|
+
deps: SafeS3EndpointLookupDeps = {},
|
|
75
|
+
): LookupFunction | undefined {
|
|
76
|
+
if (!rawEndpoint) return undefined
|
|
77
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
78
|
+
if (allowPrivate) return undefined
|
|
79
|
+
|
|
80
|
+
const { hostname } = parseOutboundUrl(rawEndpoint, {
|
|
81
|
+
errorFactory: s3EndpointErrorFactory,
|
|
82
|
+
subject: SUBJECT,
|
|
83
|
+
})
|
|
84
|
+
const endpointHostname = normalizeLookupHostname(hostname)
|
|
85
|
+
|
|
86
|
+
return (host, options, callback) => {
|
|
87
|
+
const lookupHostname = normalizeLookupHostname(host)
|
|
88
|
+
if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {
|
|
89
|
+
callback(new UnsafeS3EndpointError(
|
|
90
|
+
'blocked_hostname',
|
|
91
|
+
`S3 endpoint lookup host "${host}" does not match configured host "${endpointHostname}"`,
|
|
92
|
+
), '', 0)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {
|
|
97
|
+
errorFactory: s3EndpointErrorFactory,
|
|
98
|
+
subject: SUBJECT,
|
|
99
|
+
lookupHost: deps.lookupHost ?? defaultLookupHost,
|
|
100
|
+
}).then(({ addresses }) => {
|
|
101
|
+
if (!addresses || addresses.length === 0) {
|
|
102
|
+
const family = isIP(lookupHostname)
|
|
103
|
+
if (options.all) callback(null, [{ address: lookupHostname, family }] as ResolvedHostAddress[])
|
|
104
|
+
else callback(null, lookupHostname, family)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (options.all) {
|
|
108
|
+
callback(null, addresses.map((record) => ({ address: record.address, family: record.family })))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
callback(null, addresses[0].address, addresses[0].family)
|
|
112
|
+
}).catch((error: unknown) => {
|
|
113
|
+
callback(error instanceof Error ? error : new UnsafeS3EndpointError('dns_resolution_failed'), '', 0)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeLookupHostname(hostname: string): string {
|
|
119
|
+
let normalized = hostname.trim().toLowerCase()
|
|
120
|
+
if (normalized.startsWith('[') && normalized.endsWith(']')) {
|
|
121
|
+
normalized = normalized.slice(1, -1)
|
|
122
|
+
}
|
|
123
|
+
while (normalized.endsWith('.')) {
|
|
124
|
+
normalized = normalized.slice(0, -1)
|
|
125
|
+
}
|
|
126
|
+
return normalized
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isAllowedS3LookupHost(lookupHostname: string, endpointHostname: string): boolean {
|
|
130
|
+
if (lookupHostname === endpointHostname) return true
|
|
131
|
+
if (isIP(endpointHostname)) return false
|
|
132
|
+
return lookupHostname.endsWith(`.${endpointHostname}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildLookupValidationUrl(rawEndpoint: string, lookupHostname: string): string {
|
|
136
|
+
const { url } = parseOutboundUrl(rawEndpoint, {
|
|
137
|
+
errorFactory: s3EndpointErrorFactory,
|
|
138
|
+
subject: SUBJECT,
|
|
139
|
+
})
|
|
140
|
+
const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname
|
|
141
|
+
const port = url.port ? `:${url.port}` : ''
|
|
142
|
+
return `${url.protocol}//${host}${port}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SafeS3RequestHandlerOptions = {
|
|
146
|
+
httpAgent: HttpAgent
|
|
147
|
+
httpsAgent: HttpsAgent
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createSafeS3RequestHandler(
|
|
151
|
+
rawEndpoint: string | null | undefined,
|
|
152
|
+
deps: SafeS3EndpointLookupDeps = {},
|
|
153
|
+
): SafeS3RequestHandlerOptions | undefined {
|
|
154
|
+
const lookup = createSafeS3EndpointLookup(rawEndpoint, deps)
|
|
155
|
+
if (!lookup) return undefined
|
|
156
|
+
return {
|
|
157
|
+
httpAgent: new HttpAgent({ lookup }),
|
|
158
|
+
httpsAgent: new HttpsAgent({ lookup }),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isAllowInternalS3EndpointsEnabled(): boolean {
|
|
163
|
+
return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const defaultLookupHost: HostLookup = async (hostname) => {
|
|
167
|
+
const records = await dnsLookup(hostname, { all: true, verbatim: true })
|
|
168
|
+
return records
|
|
169
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'
|
|
2
3
|
import type { S3DriverConfig } from './s3-driver'
|
|
3
4
|
|
|
4
5
|
export const s3HealthCheck = {
|
|
@@ -67,11 +68,28 @@ export const s3HealthCheck = {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
try {
|
|
72
|
+
await assertSafeS3Endpoint(cfg.endpoint)
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'
|
|
75
|
+
const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined
|
|
76
|
+
return {
|
|
77
|
+
status: 'unhealthy',
|
|
78
|
+
message,
|
|
79
|
+
details: {
|
|
80
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
81
|
+
},
|
|
82
|
+
checkedAt,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
70
87
|
const client = new S3Client({
|
|
71
88
|
region: cfg.region ?? 'us-east-1',
|
|
72
89
|
endpoint: cfg.endpoint,
|
|
73
90
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
74
91
|
credentials: credentialConfig,
|
|
92
|
+
...(requestHandler ? { requestHandler } : {}),
|
|
75
93
|
})
|
|
76
94
|
|
|
77
95
|
try {
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
} from '@aws-sdk/client-s3'
|
|
12
12
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
13
13
|
import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
|
|
14
|
+
import {
|
|
15
|
+
assertSafeS3Endpoint,
|
|
16
|
+
assertStaticallySafeS3Endpoint,
|
|
17
|
+
createSafeS3RequestHandler,
|
|
18
|
+
} from './endpoint-safety'
|
|
14
19
|
import {
|
|
15
20
|
assertS3KeyAddressableByTenantScope,
|
|
16
21
|
assertS3KeyScopedToTenant,
|
|
@@ -71,12 +76,14 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
71
76
|
readonly key = 's3'
|
|
72
77
|
private readonly client: S3Client
|
|
73
78
|
private readonly bucket: string
|
|
79
|
+
private readonly endpoint?: string
|
|
74
80
|
private readonly pathPrefix: string
|
|
75
81
|
private readonly scope: S3TenantScope | null
|
|
76
82
|
|
|
77
83
|
constructor(config: Record<string, unknown>) {
|
|
78
84
|
const cfg = config as S3DriverConfig
|
|
79
85
|
this.bucket = cfg.bucket
|
|
86
|
+
this.endpoint = cfg.endpoint
|
|
80
87
|
this.pathPrefix = cfg.pathPrefix ?? ''
|
|
81
88
|
this.scope = cfg.organizationId && cfg.tenantId
|
|
82
89
|
? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }
|
|
@@ -109,11 +116,15 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
assertStaticallySafeS3Endpoint(cfg.endpoint)
|
|
120
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
121
|
+
|
|
112
122
|
this.client = new S3Client({
|
|
113
123
|
region: cfg.region ?? 'us-east-1',
|
|
114
124
|
endpoint: cfg.endpoint,
|
|
115
125
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
116
126
|
credentials,
|
|
127
|
+
...(requestHandler ? { requestHandler } : {}),
|
|
117
128
|
})
|
|
118
129
|
}
|
|
119
130
|
|
|
@@ -137,6 +148,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
async store(payload: StoreFilePayload): Promise<StoredFile> {
|
|
151
|
+
await this.assertEndpointSafe()
|
|
140
152
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null)
|
|
141
153
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
|
|
142
154
|
const safeName = sanitizeFileName(payload.fileName || 'file')
|
|
@@ -159,6 +171,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
159
171
|
async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
|
|
160
172
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
161
173
|
this.assertKeyAddressable(storagePath)
|
|
174
|
+
await this.assertEndpointSafe()
|
|
162
175
|
const response = await this.client.send(
|
|
163
176
|
new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
164
177
|
)
|
|
@@ -173,6 +186,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
173
186
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
174
187
|
this.assertKeyAddressable(storagePath)
|
|
175
188
|
try {
|
|
189
|
+
await this.assertEndpointSafe()
|
|
176
190
|
await this.client.send(
|
|
177
191
|
new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
178
192
|
)
|
|
@@ -207,6 +221,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
207
221
|
*/
|
|
208
222
|
async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {
|
|
209
223
|
this.assertKeyScoped(key)
|
|
224
|
+
await this.assertEndpointSafe()
|
|
210
225
|
await this.client.send(
|
|
211
226
|
new PutObjectCommand({
|
|
212
227
|
Bucket: this.bucket,
|
|
@@ -234,6 +249,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
234
249
|
}> {
|
|
235
250
|
const activeScope = scope ?? this.scope
|
|
236
251
|
assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)
|
|
252
|
+
await this.assertEndpointSafe()
|
|
237
253
|
const response = await this.client.send(
|
|
238
254
|
new ListObjectsV2Command({
|
|
239
255
|
Bucket: this.bucket,
|
|
@@ -269,6 +285,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
269
285
|
} else {
|
|
270
286
|
this.assertKeyAddressable(storagePath, scope)
|
|
271
287
|
}
|
|
288
|
+
await this.assertEndpointSafe()
|
|
272
289
|
if (operation === 'upload') {
|
|
273
290
|
const command = new PutObjectCommand({
|
|
274
291
|
Bucket: this.bucket,
|
|
@@ -280,4 +297,8 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
280
297
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
281
298
|
return getSignedUrl(this.client, command, { expiresIn })
|
|
282
299
|
}
|
|
300
|
+
|
|
301
|
+
private async assertEndpointSafe(): Promise<void> {
|
|
302
|
+
await assertSafeS3Endpoint(this.endpoint)
|
|
303
|
+
}
|
|
283
304
|
}
|