diff --git a/.env.example b/.env.example index e9c0006..2497aaa 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,8 @@ CORS_ALLOWED_ORIGIN="" # Logging NODE_ENV=development + +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID="" +CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION="" diff --git a/appStartUp.sh b/appStartUp.sh index 950c0d5..2644dd0 100755 --- a/appStartUp.sh +++ b/appStartUp.sh @@ -3,14 +3,6 @@ set -eo pipefail export DATABASE_URL=$(echo -e ${DATABASE_URL}) -# Set default schema to 'public' if not provided -if [ -z "$POSTGRES_SCHEMA" ]; then - echo "POSTGRES_SCHEMA not set, defaulting to 'public'" - export POSTGRES_SCHEMA="public" -else - echo "Using PostgreSQL schema: $POSTGRES_SCHEMA" -fi - echo "Database - running migrations." npx prisma migrate deploy diff --git a/docs/projects-showcase-media.md b/docs/projects-showcase-media.md new file mode 100644 index 0000000..547bc8d --- /dev/null +++ b/docs/projects-showcase-media.md @@ -0,0 +1,273 @@ +# CloudFront Signed URLs + Private S3 Setup + +## Purpose + +This document describes how to configure a private S3 bucket that is +only accessible through CloudFront using CloudFront Signed URLs. + +The application authenticates users with JWTs. CloudFront **does not** +validate JWTs directly. Instead: + +1. The backend validates the JWT. +2. The backend generates a short-lived CloudFront signed URL. +3. CloudFront validates the signature. +4. CloudFront retrieves the object from the private S3 bucket using an + Origin Access Control (OAC). + +------------------------------------------------------------------------ + +# Architecture + +``` text +Browser + | + | JWT + v +Projects API + | + | Verify JWT + | Generate CloudFront Signed URL + v +Browser + | + | GET https://cdn.example.com/path/file.ext?...Signature... + v +CloudFront + | + | Validate signature + | (automatic) + v +Origin Access Control (OAC) + | + | SigV4 request + v +Private S3 Bucket +``` + +------------------------------------------------------------------------ + +# S3 Configuration + +## Bucket + +- Enable **Block all public access**. +- Do not use public ACLs. +- Do not add bucket policies granting `Principal: "*"` read access. + +## Bucket Policy + +Replace the placeholders before applying. + +``` json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": { + "Service": "cloudfront.amazonaws.com" + }, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::YOUR_BUCKET/*", + "Condition": { + "StringEquals": { + "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID" + } + } + } + ] +} +``` + +------------------------------------------------------------------------ + +# CloudFront Configuration + +## 1. Origin Access Control + +Create: + + CloudFront + └── Origin access + └── Origin access controls + +Configuration: + +- Origin type: S3 +- Signing behavior: Sign requests +- Signing protocol: SigV4 + +Attach the OAC to the S3 origin. + +------------------------------------------------------------------------ + +## 2. RSA Key Pair + +Generate locally: + +``` bash +openssl genrsa -out private_key.pem 2048 + +openssl rsa \ + -pubout \ + -in private_key.pem \ + -out public_key.pem +``` + +Never commit `private_key.pem`. + +------------------------------------------------------------------------ + +## 3. Public Key + + CloudFront + └── Security + └── Public Keys + +Create a Public Key and paste the contents of `public_key.pem`. + +Record the generated **Key Pair ID**. + +------------------------------------------------------------------------ + +## 4. Key Group + + CloudFront + └── Security + └── Key Groups + +Create a Key Group containing the Public Key. + +------------------------------------------------------------------------ + +## 5. Distribution + +Edit the protected behavior. + +Configure: + +- Viewer protocol policy: Redirect HTTP to HTTPS +- Trusted Key Groups: Select the Key Group created above + +Once configured, CloudFront automatically: + +- rejects unsigned requests +- rejects expired requests +- rejects requests signed with an unknown key + +No Lambda or custom verification logic is required. + +------------------------------------------------------------------------ + +# Parameter Store + +Store the following values in AWS Systems Manager Parameter Store. + + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY + /config/projects-api-v6/appvar/CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID + +Recommended types: + +| Parameter | Type | Notes | +|---|---|---| +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY | SecureString | PEM contents of private key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY | String | PEM contents of public key | +| CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID | String | CloudFront Key Pair ID | + +The application should read these values at startup and never hardcode +or commit them. + +------------------------------------------------------------------------ + +# Backend + +Install: + +``` bash +npm install @aws-sdk/cloudfront-signer +``` + +Example: + +``` ts +import { getSignedUrl } from "@aws-sdk/cloudfront-signer"; + +const signedUrl = getSignedUrl({ + url, + keyPairId: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID!, + privateKey: process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY!, + dateLessThan: new Date(Date.now() + 5 * 60 * 1000).toISOString() +}); +``` + +`getSignedUrl()` performs local cryptographic signing only. It does not +make any AWS API calls. + +------------------------------------------------------------------------ + +# Request Flow + +``` text +Client + │ + ├── JWT + ▼ +Projects API + │ + ├── Verify JWT + ├── getSignedUrl() + ▼ +Signed URL + │ + ▼ +CloudFront + │ + ├── Validate signature + ├── Validate expiration + ▼ +S3 (via OAC) +``` + +------------------------------------------------------------------------ + +# Validation + +Expected results: + + Request Expected + --------------------------------------- ------------------- + Direct S3 URL 403 Access Denied + CloudFront URL without signature 403 Forbidden + CloudFront URL with invalid signature 403 Forbidden + CloudFront URL with expired signature 403 Forbidden + CloudFront URL with valid signature 200 OK + +------------------------------------------------------------------------ + +# Key Rotation + +1. Generate a new RSA key pair. +2. Create a new CloudFront Public Key. +3. Add the new Public Key to the existing Key Group. +4. Update Parameter Store with the new private key and key pair ID. +5. Deploy the backend. +6. Wait until all previously issued signed URLs have expired. +7. Remove the old Public Key from the Key Group. +8. Delete the old key material if no longer required. + +This sequence avoids downtime during key rotation. + +------------------------------------------------------------------------ + +# Security Checklist + +- Block Public Access enabled. +- No public bucket policy. +- OAC attached to the S3 origin. +- Bucket policy only permits the CloudFront distribution. +- Private key stored as SecureString in Parameter Store. +- Private key never committed to source control. +- Signed URLs expire after a short period (recommended: 5--15 + minutes). diff --git a/package.json b/package.json index 797178f..a72fbb2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.926.0", + "@aws-sdk/cloudfront-signer": "^3.1077.0", "@aws-sdk/s3-request-presigner": "^3.926.0", "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483cfb1..346d661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.926.0 version: 3.985.0 + '@aws-sdk/cloudfront-signer': + specifier: ^3.1077.0 + version: 3.1077.0 '@aws-sdk/s3-request-presigner': specifier: ^3.926.0 version: 3.985.0 @@ -263,6 +266,10 @@ packages: resolution: {integrity: sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/cloudfront-signer@3.1077.0': + resolution: {integrity: sha512-KjOQEbesPbpVd3NjF2fGCDbigunTY/x5fERn1RmFZ836VUT2Sh4BJpaHZ1rOLE+wsEuYS4DFHkXzbVbmJ5H5lw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.7': resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} engines: {node: '>=20.0.0'} @@ -1285,6 +1292,10 @@ packages: resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} engines: {node: '>=18.0.0'} + '@smithy/core@3.28.0': + resolution: {integrity: sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} @@ -1405,6 +1416,10 @@ packages: resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -4740,6 +4755,11 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/cloudfront-signer@3.1077.0': + dependencies: + '@smithy/core': 3.28.0 + tslib: 2.8.1 + '@aws-sdk/core@3.973.7': dependencies: '@aws-sdk/types': 3.973.1 @@ -6106,6 +6126,11 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.28.0': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -6301,6 +6326,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.15.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 diff --git a/src/api/project-showcase-post/project-showcase-post.service.spec.ts b/src/api/project-showcase-post/project-showcase-post.service.spec.ts index 2eb482a..babbe12 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.spec.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.spec.ts @@ -1,6 +1,13 @@ import { NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { ProjectShowcasePostService } from './project-showcase-post.service'; +import type { ProjectShowcasePostService } from './project-showcase-post.service'; + +jest.mock('src/shared/utils/cloudfront.utils', () => ({ + signCloudFrontUrl: jest.fn((url: string) => `${url}?signed=1`), +})); + +const { ProjectShowcasePostService: ProjectShowcasePostServiceClass } = + require('./project-showcase-post.service'); describe('ProjectShowcasePostService', () => { const prismaMock = { @@ -20,7 +27,7 @@ describe('ProjectShowcasePostService', () => { hasNamedPermission: jest.fn(), }; - let service: ProjectShowcasePostService; + let service: InstanceType; const user = { userId: '42', isMachine: false, @@ -69,7 +76,7 @@ describe('ProjectShowcasePostService', () => { } beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); permissionServiceMock.hasNamedPermission.mockReturnValue(true); prismaMock.project.findFirst.mockResolvedValue({ @@ -83,7 +90,7 @@ describe('ProjectShowcasePostService', () => { ], }); - service = new ProjectShowcasePostService( + service = new ProjectShowcasePostServiceClass( prismaMock as any, permissionServiceMock as any, ); @@ -247,7 +254,7 @@ describe('ProjectShowcasePostService', () => { { type: 'image/png', url: 'https://example.com/image.png', - createdBy: 42, + createdBy: BigInt(42), }, ], }, @@ -258,7 +265,7 @@ describe('ProjectShowcasePostService', () => { expect.objectContaining({ id: '101', type: 'image/png', - url: 'https://example.com/image.png', + url: 'https://example.com/image.png?signed=1', }), ]); }); @@ -413,7 +420,7 @@ describe('ProjectShowcasePostService', () => { { type: 'video/mp4', url: 'https://example.com/video.mp4', - createdBy: 42, + createdBy: BigInt(42), }, ], }, @@ -424,7 +431,7 @@ describe('ProjectShowcasePostService', () => { expect.objectContaining({ id: '102', type: 'video/mp4', - url: 'https://example.com/video.mp4', + url: 'https://example.com/video.mp4?signed=1', }), ]); }); diff --git a/src/api/project-showcase-post/project-showcase-post.service.ts b/src/api/project-showcase-post/project-showcase-post.service.ts index 8ba1f8a..d3ab149 100644 --- a/src/api/project-showcase-post/project-showcase-post.service.ts +++ b/src/api/project-showcase-post/project-showcase-post.service.ts @@ -18,6 +18,7 @@ import { CreateProjectShowcasePostDto } from './dto/create-project-showcase-post import { ProjectShowcasePostListQueryDto } from './dto/project-showcase-post-list-query.dto'; import { ProjectShowcasePostResponseDto } from './dto/project-showcase-post-response.dto'; import { UpdateProjectShowcasePostDto } from './dto/update-project-showcase-post.dto'; +import { signCloudFrontUrl } from 'src/shared/utils/cloudfront.utils'; @Injectable() export class ProjectShowcasePostService { @@ -490,7 +491,7 @@ export class ProjectShowcasePostService { media: post.media.map((entry) => ({ id: String(entry.id), type: entry.type, - url: entry.url, + url: signCloudFrontUrl(entry.url), createdAt: entry.createdAt, createdBy: String(entry.createdBy), })), diff --git a/src/shared/config/app.config.ts b/src/shared/config/app.config.ts index 3578c9f..7efdbd5 100644 --- a/src/shared/config/app.config.ts +++ b/src/shared/config/app.config.ts @@ -71,6 +71,33 @@ export const APP_CONFIG = { process.env.PRESIGNED_URL_EXPIRATION, 3600, ), + /** + * CloudFront key pair id used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID`. + */ + cloudFrontProjectShowcaseMediaKeyPairId: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID || + process.env.CLOUDFRONT_KEY_PAIR_ID, + /** + * CloudFront private key used to sign project showcase media URLs. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY`. + */ + cloudFrontProjectShowcaseMediaPrivateKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY, + /** + * CloudFront public key used to validate signed URLs (not used by service). + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY`. + */ + cloudFrontProjectShowcaseMediaPublicKey: + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PUBLIC_KEY, + /** + * CloudFront signed URL expiry in seconds. + * Env: `CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION`, default: `600`. + */ + cloudFrontProjectShowcaseMediaUrlExpiration: parseNumberEnv( + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_URL_EXPIRATION, + 600, + ), /** * Maximum number of phase products per phase. * Env: `MAX_PHASE_PRODUCT_COUNT`, default: `20`. diff --git a/src/shared/utils/cloudfront.utils.spec.ts b/src/shared/utils/cloudfront.utils.spec.ts new file mode 100644 index 0000000..33a85f4 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.spec.ts @@ -0,0 +1,33 @@ +describe('signCloudFrontUrl', () => { + const url = 'https://cdn.example.com/private/report.pdf'; + const mockSignedUrl = `${url}?signature=abc`; + + beforeEach(() => { + jest.resetModules(); + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_KEY_PAIR_ID; + delete process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY; + }); + + it('returns original URL when signing keys are missing', () => { + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(url); + }); + }); + + it('signs the URL when CloudFront key pair id and private key are configured', () => { + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_KEY_PAIR_ID = 'KP_ID'; + process.env.CLOUDFRONT_PROJECT_SHOWCASE_MEDIA_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAuR1qVAvV+7Z2qmeJ\n-----END PRIVATE KEY-----'; + + jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedUrl: jest.fn(() => mockSignedUrl), + })); + + jest.isolateModules(() => { + const { signCloudFrontUrl } = require('./cloudfront.utils'); + expect(signCloudFrontUrl(url)).toBe(mockSignedUrl); + }); + }); +}); diff --git a/src/shared/utils/cloudfront.utils.ts b/src/shared/utils/cloudfront.utils.ts new file mode 100644 index 0000000..c65bb17 --- /dev/null +++ b/src/shared/utils/cloudfront.utils.ts @@ -0,0 +1,68 @@ +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; +import { APP_CONFIG } from 'src/shared/config/app.config'; + +function normalizePemKey(rawKey: string | undefined): string | undefined { + if (!rawKey) { + return undefined; + } + + let normalized = rawKey.trim(); + + while (normalized.includes('\\n')) { + normalized = normalized.replace(/\\n/g, '\n'); + } + + const pemMatch = normalized.match( + /-----BEGIN ([A-Z0-9 ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----/s, + ); + if (pemMatch) { + const [, keyType, keyBody] = pemMatch; + const compactBody = keyBody.replace(/\s+/g, ''); + + if (compactBody.length > 0) { + const wrappedBody = compactBody.match(/.{1,64}/g)?.join('\n') || ''; + normalized = [ + `-----BEGIN ${keyType}-----`, + wrappedBody, + `-----END ${keyType}-----`, + ].join('\n'); + } + } + + if (!normalized.includes('-----BEGIN')) { + try { + const decoded = Buffer.from(normalized, 'base64').toString('utf8'); + if (decoded.includes('-----BEGIN')) { + normalized = decoded; + } + } catch { + // Keep the original value if decoding fails. + } + } + + return normalized.trim(); +} + +export function signCloudFrontUrl(url: string): string { + const keyPairId = APP_CONFIG.cloudFrontProjectShowcaseMediaKeyPairId; + const privateKey = normalizePemKey( + APP_CONFIG.cloudFrontProjectShowcaseMediaPrivateKey, + ); + + if (!keyPairId || !privateKey) { + return url; + } + + try { + return getSignedUrl({ + url, + keyPairId, + privateKey, + dateLessThan: new Date( + Date.now() + APP_CONFIG.cloudFrontProjectShowcaseMediaUrlExpiration * 1000, + ).toISOString(), + }); + } catch { + return url; + } +}