Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/harden-storage-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@nestm/storage': minor
---

Harden storage integration boundaries with cross-copy-safe `StorageError`
detection, a package-owned S3 driver factory, conditional staged-object
promotion, and a mandatory parsed key policy plus signed-transfer limits for
the optional HTTP gateway.
7 changes: 7 additions & 0 deletions .changeset/map-foreign-files-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@nestm/storage': patch
---

Map structurally branded `files-sdk` errors, including errors wrapped across
duplicate package copies, so missing objects retain the `NOT_FOUND` storage
error code.
124 changes: 111 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ consumers do not download NestJS.
## Install

```sh
pnpm add @nestm/storage@alpha files-sdk@2.2.2
pnpm add @nestm/storage@alpha
```

The package-owned S3 factory needs no direct `files-sdk` import. Install the
pinned engine explicitly only when the application imports another adapter
such as `files-sdk/gcs`:

```sh
pnpm add files-sdk@2.2.2
```

Install only the native SDKs required by the chosen provider. For example:
Expand Down Expand Up @@ -82,14 +90,15 @@ runtime or declaration imports. Provider adapters remain available through

## Configure named stores

Create provider adapters with `files-sdk`, wrap them through the explicit
bridge, and register the resulting drivers:
Use the package-owned S3 factory when applicable. For other providers, create a
`files-sdk` adapter, wrap it through the explicit bridge, and register the
resulting driver:

```ts
import { Module } from '@nestjs/common';
import { s3 } from 'files-sdk/s3';
import { gcs } from 'files-sdk/gcs';
import { createFilesSdkDriver } from '@nestm/storage/files-sdk';
import { createS3StorageDriver } from '@nestm/storage/files-sdk/s3';
import { StorageModule } from '@nestm/storage';

export const StorageKey = {
Expand All @@ -104,11 +113,11 @@ export const StorageKey = {
stores: [
{
name: StorageKey.MEDIA,
driver: createFilesSdkDriver({
adapter: s3({
driver: createS3StorageDriver({
adapter: {
bucket: 'media',
region: 'us-east-1',
}),
},
}),
},
{
Expand Down Expand Up @@ -194,11 +203,11 @@ StorageModule.forRootAsync({
name: 'media',
inject: [ConfigService],
useFactory: (config: ConfigService) =>
createFilesSdkDriver({
adapter: s3({
createS3StorageDriver({
adapter: {
bucket: config.getOrThrow('MEDIA_BUCKET'),
region: config.getOrThrow('AWS_REGION'),
}),
},
}),
},
],
Expand All @@ -221,6 +230,7 @@ whitespace.
`StorageClient` exposes:

- `upload`, `downloadStream`, `head`, `exists`, `delete`, `copy`, and `move`;
- conditional staged-object `promote` when the driver advertises it;
- `list`, cursor-aware `listAll`, and lazy `search`;
- `signDownload` and discriminated PUT/POST `signUpload`;
- `uploadMany`, `downloadMany`, `headMany`, `existsMany`, and `deleteMany`;
Expand Down Expand Up @@ -250,6 +260,37 @@ Node `Readable` uploads are accepted and converted to Web streams without
buffering. Provider capability gaps fail closed with `StorageError` rather than
silently discarding a range, metadata, or cache-control request.

### Race-free staged-object promotion

The S3 bridge advertises ETag- and version-conditional server-side copy. This
lets an application validate a staged object and copy that exact source to its
final key instead of re-reading whichever bytes occupy the staging key later:

```ts
import { StorageError, StorageErrorCode } from '@nestm/storage';

const staged = await media.head(stagingKey);
if (staged.etag === undefined) {
throw new StorageError('Provider did not return a source ETag.', {
code: StorageErrorCode.NOT_SUPPORTED,
});
}

// Validate size, declared MIME, and magic bytes before this call.
await media.file(stagingKey).promoteTo(finalKey, {
sourceEtag: staged.etag,
});

// Commit ready metadata first. Promotion deliberately retains the staged
// object so a failed database commit remains recoverable.
await media.delete(stagingKey);
```

`sourceVersion` can select an immutable S3 version and may be combined with
`sourceEtag`. A promotion without either identity is rejected. Drivers that do
not publish `capabilities.conditionalCopy` fail with `NOT_SUPPORTED` rather
than falling back to an unsafe ordinary copy.

### Resumable uploads

```ts
Expand Down Expand Up @@ -301,20 +342,42 @@ The gateway lives at `@nestm/storage/gateway` and is never mounted by
`StorageModule`.

```ts
import { Module } from '@nestjs/common';
import { Injectable, Module } from '@nestjs/common';
import {
StorageGatewayModule,
StorageGatewayOperation,
type StorageGatewayKeyPolicy,
} from '@nestm/storage/gateway';

@Injectable()
class TenantStorageKeyPolicy implements StorageGatewayKeyPolicy {
resolve({ input, request, target }) {
const tenantId = tenantIdFromAuthenticatedRequest(request);
const root = `tenants/${base64url(tenantId)}`;
if (target === 'pattern') {
// Search is already constrained by the separately resolved prefix.
return input?.value ?? '*';
}
return `${root}/${input?.value ?? ''}`;
}
}

@Module({
providers: [TenantStorageKeyPolicy],
exports: [TenantStorageKeyPolicy],
})
class StoragePolicyModule {}

@Module({
imports: [
AppStorageModule,
AuthModule,
StoragePolicyModule,
StorageGatewayModule.register({
imports: [AppStorageModule, AuthModule],
imports: [AppStorageModule, AuthModule, StoragePolicyModule],
store: 'media',
guards: [JwtAuthGuard],
keyPolicy: TenantStorageKeyPolicy,
mode: 'hybrid',
operations: [
StorageGatewayOperation.DOWNLOAD,
Expand All @@ -325,6 +388,9 @@ import {
StorageGatewayOperation.SIGN_UPLOAD,
],
maxUploadBytes: 100 * 1024 * 1024,
maxSignedUploadBytes: 10 * 1024 * 1024,
signedUploadContentTypes: ['image/jpeg', 'image/png'],
maxSignedUrlExpiresIn: 900,
maxListResults: 1000,
maxSearchResults: 1000,
proxyInlineContentTypes: ['image/jpeg', 'image/png'],
Expand All @@ -338,12 +404,39 @@ Registration fails without at least one existing Nest guard. The only bypass is
the explicit `allowUnauthenticated: true` development escape hatch. Operations
are deny-by-default and must be allowlisted individually.

Registration also fails without a `keyPolicy`. Guards answer whether a request
may reach the gateway; the key policy independently resolves every parsed
`key`, `prefix`, search `pattern`, `from`, and `to` value to the exact provider
path. It runs even when a list/search prefix was omitted, so the policy can
always impose a tenant root. Key-policy providers may be request scoped.
Returned paths are parsed again and reject absolute paths, backslashes, control
characters, empty segments, and dot/parent segments.

Existing single-tenant applications can temporarily set
`unsafeAllowUnscopedKeys: true` instead of `keyPolicy`. The name is intentional:
it preserves caller-controlled provider keys and must not be used on an exposed
or multi-tenant gateway. It cannot be combined with `keyPolicy`.

Proxy downloads default to `Content-Disposition: attachment` and always send
`X-Content-Type-Options: nosniff`. Add only trusted, non-active MIME types to
`proxyInlineContentTypes` when browser rendering is required. Search responses
are capped by `maxSearchResults`, and list pages by `maxListResults` (both
1,000 by default).

Every signed URL is capped by `maxSignedUrlExpiresIn` (3,600 seconds by
default). Signed uploads always carry a provider-enforced maximum size, capped
by `maxSignedUploadBytes`, and require an exact lowercase MIME type from
`signedUploadContentTypes`. The default direct-upload allowlist contains only
`application/octet-stream`. Gateway callers may request only the literal
`attachment` or `inline` response disposition; arbitrary response-header text
and filenames are rejected. A driver must also advertise
`signedUploadPolicy.contentType` and `signedUploadPolicy.sizeRange`; otherwise
the gateway refuses to mint the URL. `createS3StorageDriver()` advertises both
and uses S3 POST policy conditions. Signed downloads similarly require
`signedDownloadPolicy.expiresIn`. The S3 factory advertises it only when no
permanent `publicBaseUrl` was configured, preventing a configured TTL from
silently returning a non-expiring public link.

The fixed gateway prefix is `/storage`:

| Method | Path | Operation |
Expand Down Expand Up @@ -394,9 +487,14 @@ try {
}
```

`files-sdk` `NotFound` failures retain `StorageErrorCode.NOT_FOUND`, including
when a provider adapter and this driver resolve separate copies of `files-sdk`.
`isStorageError()` likewise recognizes branded and exact legacy structural
errors produced by a duplicated `@nestm/storage` package copy.

Capability flags cover range reads, native byte-level upload progress,
delimiter listing, metadata, cache control, resumable uploads, server-side
copy, and signed transfers.
copy, conditional promotion, and signed transfers.
Provider-specific native clients are intentionally not exposed from the root
package.

Expand Down
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,26 @@ cross-store pruning, or dependency compromise are especially useful.
For a vulnerability originating in NestJS, `files-sdk`, or a provider SDK,
follow that project's security policy as well. You may still report it here
privately when this integration needs a mitigation.

## Gateway security boundary

Mounting the optional HTTP gateway requires both authentication guards and a
`StorageGatewayKeyPolicy`. The policy receives parsed paths and must derive the
provider key space from trusted request context. Do not treat a client prefix
or object key as a tenant identifier. `unsafeAllowUnscopedKeys` exists only to
migrate trusted single-tenant deployments and is unsafe on an exposed gateway.

Signed URL expiry, signed-upload byte size, and signed-upload MIME are bounded
by module configuration and cannot be increased by a request. Only `inline`
and `attachment` content dispositions are accepted. File type still must be
verified from magic bytes after upload; a signed MIME condition proves only
what header the uploader supplied. The gateway rejects signed-upload drivers
that do not advertise provider-enforced content-type and size-range policies.
It also rejects download adapters that cannot guarantee the requested expiry;
in particular, the S3 bridge does not treat `publicBaseUrl` links as expiring.

For staged uploads on S3, use `promote`/`promoteTo` with the ETag returned by
the validated `head` or with an immutable provider version. Ordinary `copy`
does not protect against a staging-key replay between validation and copy.
Conditional promotion keeps the staging source; delete it only after the
application's metadata transaction commits.
24 changes: 24 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"types": "./dist/files-sdk/index.d.ts",
"import": "./dist/files-sdk/index.js"
},
"./files-sdk/s3": {
"types": "./dist/files-sdk/s3/index.d.ts",
"import": "./dist/files-sdk/s3/index.js"
},
"./gateway": {
"types": "./dist/gateway/index.d.ts",
"import": "./dist/gateway/index.js"
Expand Down Expand Up @@ -89,12 +93,28 @@
"files-sdk": "2.2.2"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/lib-storage": "^3.700.0",
"@aws-sdk/s3-presigned-post": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@nestjs/common": "^12.0.0-alpha.5",
"@nestjs/core": "^12.0.0-alpha.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"peerDependenciesMeta": {
"@aws-sdk/client-s3": {
"optional": true
},
"@aws-sdk/lib-storage": {
"optional": true
},
"@aws-sdk/s3-presigned-post": {
"optional": true
},
"@aws-sdk/s3-request-presigner": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
Expand All @@ -109,6 +129,10 @@
}
},
"devDependencies": {
"@aws-sdk/client-s3": "3.1101.0",
"@aws-sdk/lib-storage": "3.1101.0",
"@aws-sdk/s3-presigned-post": "3.1101.0",
"@aws-sdk/s3-request-presigner": "3.1101.0",
"@changesets/cli": "2.31.1",
"@nestjs/common": "12.0.0-alpha.5",
"@nestjs/core": "12.0.0-alpha.5",
Expand Down
Loading
Loading