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
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ NODE_ENV=development
PORT=3000
HOST=localhost

# CORS - Allow localhost in development
CORS_ORIGIN=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173

# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/propchain_dev

Expand Down
18 changes: 17 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ NODE_ENV=development
PORT=3000
HOST=0.0.0.0
API_PREFIX=api
CORS_ORIGIN=*
# CORS Configuration:
# - Development: Use specific localhost origins (e.g., http://localhost:3000,http://localhost:5173)
# - Production/Staging: Use specific production domains (e.g., https://propchain.io,https://app.propchain.io)
# - Wildcard '*' is only allowed in development/test environments
# - Multiple origins can be comma-separated: https://example.com,https://api.example.com
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
SWAGGER_ENABLED=true

# Database Configuration
Expand Down Expand Up @@ -48,6 +53,17 @@ EMAIL_FROM=noreply@propchain.io
COINGECKO_API_KEY=your-coingecko-api-key
OPENSEA_API_KEY=your-opensea-api-key
ZILLOW_API_KEY=your-zillow-api-key

# File Upload Security Configuration
MAX_FILE_SIZE=10485760
MAX_FILES_PER_UPLOAD=10
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/gif,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document
VALIDATE_MAGIC_NUMBERS=true
MALWARE_SCANNING_ENABLED=true
CLAMAV_HOST=localhost
CLAMAV_PORT=3310
CLAMAV_TIMEOUT=30000
MAX_SCAN_SIZE_BYTES=26214400
REDFIN_API_KEY=your-redfin-api-key
CORE_LOGIC_API_KEY=your-core-logic-api-key
MAXMIND_LICENSE_KEY=your-maxmind-license-key
Expand Down
5 changes: 5 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ NODE_ENV=production
PORT=3000
HOST=0.0.0.0

# CORS - REQUIRED: Specify production domains (comma-separated)
# Example: CORS_ORIGIN=https://propchain.io,https://app.propchain.io
# WARNING: Wildcard '*' is NOT allowed in production
CORS_ORIGIN=https://your-production-domain.com

# Database
DATABASE_URL=postgresql://user:secure_password@prod-db:5432/propchain_prod

Expand Down
5 changes: 5 additions & 0 deletions .env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ NODE_ENV=staging
PORT=3000
HOST=0.0.0.0

# CORS - REQUIRED: Specify staging domains (comma-separated)
# Example: CORS_ORIGIN=https://staging.propchain.io,https://staging-app.propchain.io
# WARNING: Wildcard '*' is NOT allowed in staging
CORS_ORIGIN=https://your-staging-domain.com

# Database
DATABASE_URL=postgresql://user:password@staging-db:5432/propchain_staging

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"start:debug": "nest start --debug --watch",
"start:staging": "cross-env NODE_ENV=staging node dist/main",
"start:prod": "cross-env NODE_ENV=production node dist/main",

"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --config ./jest.config.js",
"test:watch": "jest --config ./jest.config.js --watch",
Expand Down
129 changes: 129 additions & 0 deletions src/config/utils/cors-origin.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* CORS Origin Validator Utility
* Validates and parses CORS origins from configuration
*/
export class CorsOriginValidator {
/**
* Validate and parse CORS origins from environment variable
* Supports:
* - '*' (wildcard - only allowed in development/test)
* - Single origin: 'https://example.com'
* - Multiple origins: 'https://example.com,https://api.example.com'
*
* @param origin - The CORS_ORIGIN environment variable value
* @param nodeEnv - Current NODE_ENV
* @returns Array of allowed origins or false if invalid
*/
static validate(origin: string | undefined, nodeEnv: string): string[] | false {
// If not set, default based on environment
if (!origin || origin.trim() === '') {
if (nodeEnv === 'development' || nodeEnv === 'test') {
console.warn('[CorsOriginValidator] CORS_ORIGIN not set. Using default allow-all in development/test');
return ['*'];
}
console.error('[CorsOriginValidator] CORS_ORIGIN is required in production/staging');
return false;
}

const trimmedOrigin = origin.trim();

// Handle wildcard
if (trimmedOrigin === '*') {
if (nodeEnv === 'production' || nodeEnv === 'staging') {
console.error('[CorsOriginValidator] Wildcard (*) CORS origin is not allowed in production/staging');
return false;
}
console.warn('[CorsOriginValidator] CORS configured to allow all origins (development/test mode)');
return ['*'];
}

// Parse comma-separated origins
const origins = trimmedOrigin.split(',').map(o => o.trim());
const validatedOrigins: string[] = [];
const urlPattern = /^https?:\/\/[^\s/$.?#].[^\/]*$/;

for (const origin of origins) {
// Reject wildcard in list
if (origin === '*') {
console.error('[CorsOriginValidator] Wildcard (*) found in origin list - not allowed');
return false;
}

// Check for localhost in production/staging
if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) {
if (nodeEnv === 'production' || nodeEnv === 'staging') {
console.error(`[CorsOriginValidator] Localhost origin "${origin}" is not allowed in production/staging`);
return false;
}
validatedOrigins.push(origin);
continue;
}

// Validate URL format (basic check)
if (!urlPattern.test(origin)) {
console.error(`[CorsOriginValidator] Invalid origin format: "${origin}". Expected valid URL (e.g., https://example.com)`);
return false;
}

validatedOrigins.push(origin);
}

if (validatedOrigins.length === 0) {
console.error('[CorsOriginValidator] No valid CORS origins provided');
return false;
}

console.log(`[CorsOriginValidator] CORS origins validated: ${validatedOrigins.join(', ')}`);
return validatedOrigins;
}

/**
* Parse CORS_ORIGIN string to array of origins
* Returns array suitable for NestJS CORS configuration
*
* @param origin - The CORS_ORIGIN value
* @returns Array of origins
*/
static parseForNestJs(origin: string | undefined): string[] {
if (!origin) {
return ['*'];
}

if (origin.trim() === '*') {
return ['*'];
}

return origin.split(',').map(o => o.trim());
}

/**
* Check if origin is in the allowed list
* Useful for custom CORS validation
*
* @param origin - The origin to check
* @param allowedOrigins - Array of allowed origins
* @returns true if origin is allowed
*/
static isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
// Wildcard allows all
if (allowedOrigins.includes('*')) {
return true;
}

// Check exact match
if (allowedOrigins.includes(origin)) {
return true;
}

// Check domain match (origin without trailing slash)
const normalizedOrigin = origin.endsWith('/') ? origin.slice(0, -1) : origin;
for (const allowed of allowedOrigins) {
const normalizedAllowed = allowed.endsWith('/') ? allowed.slice(0, -1) : allowed;
if (normalizedOrigin === normalizedAllowed) {
return true;
}
}

return false;
}
}
56 changes: 55 additions & 1 deletion src/config/validation/config.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
import * as Joi from 'joi';

/**
* Custom validation for CORS origins
* - '*' is only allowed in development/test environments
* - Production/staging must use specific domains
*/
const corsOriginValidation = (value: string, helpers: Joi.CustomHelpers) => {
const nodeEnv = Joi.ref('$NODE_ENV');

// If no value provided, error
if (!value || value.trim() === '') {
return helpers.error('cors.origin.required');
}

// Allow '*' only in development and test
if (value === '*') {
const env = helpers.prefs?.context?.NODE_ENV || 'development';
if (env !== 'development' && env !== 'test') {
return helpers.error('cors.origin.wildcard.notAllowed');
}
return value;
}

// Validate individual origins (comma-separated)
const origins = value.split(',').map(o => o.trim());
const urlPattern = /^https?:\/\/[\w.-]+(:\d+)?(\/.*)?$/;

for (const origin of origins) {
if (origin === '*') {
return helpers.error('cors.origin.wildcard.notAllowed');
}

// Allow 'http://localhost' and 'http://localhost:*' variants
if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) {
const env = helpers.prefs?.context?.NODE_ENV || 'development';
if (env !== 'development' && env !== 'test') {
return helpers.error('cors.origin.localhost.notAllowed');
}
continue;
}

// Validate URL format
if (!urlPattern.test(origin)) {
return helpers.error('cors.origin.invalidFormat', { origin });
}
}

return value;
};

/**
* Joi validation schema for application configuration
*/
Expand All @@ -9,7 +58,12 @@ export const configValidationSchema = Joi.object({
PORT: Joi.number().default(3000),
HOST: Joi.string().default('0.0.0.0'),
API_PREFIX: Joi.string().default('api'),
CORS_ORIGIN: Joi.string().default('*'),
CORS_ORIGIN: Joi.string().custom(corsOriginValidation).default('*').messages({
'cors.origin.required': 'CORS_ORIGIN is required',
'cors.origin.wildcard.notAllowed': 'Wildcard (*) origin is not allowed in production/staging. Specify explicit allowed origins.',
'cors.origin.localhost.notAllowed': 'Localhost origins are not allowed in production/staging',
'cors.origin.invalidFormat': 'Invalid origin format: "{{origin}}". Must be a valid URL (e.g., https://example.com)',
}),
SWAGGER_ENABLED: Joi.boolean().default(true),

// Database
Expand Down
20 changes: 16 additions & 4 deletions src/documents/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiHeader, ApiConsumes } from '@nestjs/swagger';
import { DocumentFilesUploadInterceptor, DocumentFileUploadInterceptor } from './interceptors/document-upload.interceptor';
import { SecureFileValidator } from '../security/validators/secure-file.validator';
import { DocumentAccessContext, DocumentMetadataInput, DocumentSearchFilters } from './document.model';
import { DocumentService } from './document.service';
import {
Expand All @@ -27,10 +28,13 @@ import {
@ApiTags('documents')
@Controller('documents')
export class DocumentController {
constructor(private readonly documentService: DocumentService) {}
constructor(
private readonly documentService: DocumentService,
private readonly secureFileValidator: SecureFileValidator,
) {}

@Post('upload')
@UseInterceptors(FilesInterceptor('files'))
@UseInterceptors(DocumentFilesUploadInterceptor)
@ApiOperation({ summary: 'Upload documents with metadata' })
@ApiConsumes('multipart/form-data')
@ApiHeader({ name: 'x-user-id', description: 'User ID', required: true })
Expand All @@ -43,13 +47,18 @@ export class DocumentController {
@Headers('x-user-id') userId: string,
@Headers('x-user-roles') rolesHeader?: string,
) {
// Validate each file with comprehensive security checks
for (const file of files) {
await this.secureFileValidator.validate(file);
}

const context = this.buildAccessContext(userId, rolesHeader);
const metadata = this.parseMetadataInput(body);
return this.documentService.uploadDocuments(files, metadata, context);
}

@Post(':id/version')
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(DocumentFileUploadInterceptor)
@ApiOperation({ summary: 'Add a new version to existing document' })
@ApiConsumes('multipart/form-data')
@ApiParam({ name: 'id', description: 'Document ID' })
Expand All @@ -63,6 +72,9 @@ export class DocumentController {
@Headers('x-user-id') userId: string,
@Headers('x-user-roles') rolesHeader?: string,
) {
// Validate file with comprehensive security checks
await this.secureFileValidator.validate(file);

const context = this.buildAccessContext(userId, rolesHeader);
return this.documentService.addDocumentVersion(documentId, file, context);
}
Expand Down
18 changes: 16 additions & 2 deletions src/documents/document.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import axios from 'axios';
import sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';
import storageConfig, { StorageConfig } from '../config/storage.config';
import { MalwareScannerService } from '../security/services/malware-scanner.service';
import {
DocumentAccessContext,
DocumentAccessLevel,
Expand Down Expand Up @@ -187,6 +188,7 @@ export class DocumentService {
@Inject(STORAGE_CONFIG) private readonly config: StorageConfig = storageConfig(),
@Inject(STORAGE_PROVIDER)
private readonly storageProvider: StorageProvider = new InMemoryStorageProvider(storageConfig()),
private readonly malwareScanner?: MalwareScannerService,
) {}

async uploadDocuments(
Expand Down Expand Up @@ -347,7 +349,7 @@ export class DocumentService {
versionNumber = 1,
): Promise<DocumentVersion> {
await this.validateFile(file);
this.scanForVirus(file.buffer);
await this.scanForVirus(file.buffer, file.originalname);

const checksum = DocumentService.hashBuffer(file.buffer);
const storageKey = this.buildStorageKey(documentId, versionNumber, file.originalname);
Expand Down Expand Up @@ -426,7 +428,19 @@ export class DocumentService {
}
}

private scanForVirus(buffer: Buffer): void {
private async scanForVirus(buffer: Buffer, filename?: string): Promise<void> {
// Use the real MalwareScannerService if available
if (this.malwareScanner) {
const result = await this.malwareScanner.scanFile(buffer, filename);
if (!result.isClean) {
throw new BadRequestException(
`Malware detected in file: ${result.virusName || 'Unknown virus'}`,
);
}
return;
}

// Fallback to basic signature check if no malware scanner is available
if (buffer.toString('utf8').includes(DocumentService.virusSignature)) {
throw new BadRequestException('File failed virus scan');
}
Expand Down
Loading
Loading