Skip to content
Open
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
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ DASHBOARD_PORT=2886
# BASE_URL=https://api.yourdomain.com
# DASHBOARD_URL=https://dashboard.yourdomain.com

# CORS Configuration (comma-separated origins, or * for all)
CORS_ORIGINS=*
# CORS Configuration (comma-separated origins; avoid * in production)
CORS_ORIGINS=https://dashboard.yourdomain.com

# Trust reverse proxy (Nginx/Traefik) for X-Forwarded-* headers
TRUST_PROXY=true

# Max JSON body size (Express limit)
BODY_SIZE_LIMIT=5mb

# SSL/TLS (enable for production with real domain)
# TRAEFIK_ACME_EMAIL=admin@yourdomain.com
Expand Down Expand Up @@ -94,6 +100,8 @@ S3_SECRET_KEY=minioadmin
WEBHOOK_TIMEOUT=10000 # Timeout in milliseconds
WEBHOOK_MAX_RETRIES=3 # Number of retry attempts
WEBHOOK_RETRY_DELAY=5000 # Delay between retries in ms
WEBHOOK_SECRET= # Default HMAC secret for new webhooks (min 32 chars)
WEBHOOK_REQUIRE_SECRET=true # Require secret on every webhook in production

# =============================================================================
# RATE LIMITING
Expand All @@ -111,7 +119,7 @@ PLUGINS_DIR=./data/plugins # Plugin directory
# SECURITY
# =============================================================================
# Master API key (leave empty to disable, or set to secure value)
API_MASTER_KEY=
API_MASTER_KEY= # Master key for REST API (min 32 chars recommended)

# =============================================================================
# DEVELOPER SETTINGS
Expand Down
9 changes: 7 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module, DynamicModule, Type } from '@nestjs/common';
import { Module, DynamicModule, Type, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
Expand Down Expand Up @@ -26,6 +26,7 @@ import { CatalogModule } from './modules/catalog/catalog.module';
import { HooksModule } from './core/hooks';
import { PluginsModule } from './core/plugins';
import { PluginsApiModule } from './modules/plugins/plugins.module';
import { SecurityMiddleware } from './common/security/security.middleware';

// Only import QueueModule if explicitly enabled to avoid Redis connection errors
const queueModules: Array<Type | DynamicModule> = [];
Expand Down Expand Up @@ -161,4 +162,8 @@ if (process.env.QUEUE_ENABLED === 'true') {
PluginsApiModule, // Phase 5: Plugins API
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(SecurityMiddleware).forRoutes('*');
}
}
19 changes: 19 additions & 0 deletions src/common/security/secure-compare.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as crypto from 'crypto';

/**
* Constant-time string comparison to prevent timing attacks on secrets.
*/
export function secureCompare(a: string, b: string): boolean {
if (!a || !b) {
return false;
}

const bufA = Buffer.from(a);
const bufB = Buffer.from(b);

if (bufA.length !== bufB.length) {
return false;
}

return crypto.timingSafeEqual(bufA, bufB);
}
36 changes: 36 additions & 0 deletions src/common/security/webhook-signature.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as crypto from 'crypto';

const SIGNATURE_PREFIX = 'sha256=';

/**
* Generate HMAC-SHA256 signature for outbound webhook payloads.
*/
export function generateWebhookSignature(payload: string, secret: string): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
return `${SIGNATURE_PREFIX}${hmac.digest('hex')}`;
}

/**
* Verify an incoming webhook signature (for consumers receiving OpenWA events).
*/
export function verifyWebhookSignature(
payload: string | Buffer,
signatureHeader: string | undefined,
secret: string,
): boolean {
if (!signatureHeader || !secret) {
return false;
}

const expected = generateWebhookSignature(
typeof payload === 'string' ? payload : payload.toString('utf8'),
secret,
);

try {
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
} catch {
return false;
}
}
10 changes: 10 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ export default () => ({
timeout: parseInt(process.env.WEBHOOK_TIMEOUT || '10000', 10),
maxRetries: parseInt(process.env.WEBHOOK_MAX_RETRIES || '3', 10),
retryDelay: parseInt(process.env.WEBHOOK_RETRY_DELAY || '5000', 10),
defaultSecret: process.env.WEBHOOK_SECRET,
requireSecret: process.env.WEBHOOK_REQUIRE_SECRET === 'true' || process.env.NODE_ENV === 'production',
},

// Security
security: {
apiMasterKey: process.env.API_MASTER_KEY,
bodySizeLimit: process.env.BODY_SIZE_LIMIT || '5mb',
trustProxy: process.env.TRUST_PROXY !== 'false',
enableSwagger: process.env.ENABLE_SWAGGER !== 'false',
},

// API configuration
Expand Down
70 changes: 47 additions & 23 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
import { ShutdownService } from './common/services/shutdown.service';
import * as dotenv from 'dotenv';
Expand Down Expand Up @@ -65,7 +67,18 @@ STORAGE_PATH=./data/media
}

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const bodySizeLimit = process.env.BODY_SIZE_LIMIT || '5mb';
const isProduction = process.env.NODE_ENV === 'production';

const app = await NestFactory.create(AppModule, { bodyParser: false });

const httpAdapter = app.getHttpAdapter().getInstance();
if (process.env.TRUST_PROXY !== 'false') {
httpAdapter.set('trust proxy', process.env.TRUST_PROXY === 'true' ? true : 1);
}

app.use(json({ limit: bodySizeLimit }));
app.use(urlencoded({ extended: true, limit: bodySizeLimit }));

// Enable shutdown hooks for graceful shutdown
app.enableShutdownHooks();
Expand Down Expand Up @@ -103,14 +116,19 @@ async function bootstrap() {
}),
);

// CORS Configuration (Phase 3 Security Audit)
const allowedOrigins = process.env.CORS_ORIGINS?.split(',').map(o => o.trim()) || ['*'];
// CORS — restrict to trusted origins (wildcard disabled in production)
const corsEnv = process.env.CORS_ORIGINS?.split(',').map(o => o.trim()).filter(Boolean) || [];
const allowsWildcard = corsEnv.includes('*');
if (isProduction && allowsWildcard) {
console.warn('[Bootstrap] CORS_ORIGINS=* is not allowed in production; using same-origin only.');
}
const allowedOrigins = isProduction && allowsWildcard ? [] : corsEnv.length > 0 ? corsEnv : ['*'];

app.enableCors({
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// Allow requests with no origin (mobile apps, Postman, server-to-server)
if (!origin) return callback(null, true);

// Check if wildcard or origin matches
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
Expand Down Expand Up @@ -140,30 +158,36 @@ async function bootstrap() {
}),
);

// Swagger documentation
const config = new DocumentBuilder()
.setTitle('OpenWA API')
.setDescription('Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API')
.setVersion('0.1.6')
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'X-API-Key')
.addTag('sessions', 'WhatsApp session management')
.addTag('messages', 'Send and manage messages')
.addTag('webhooks', 'Webhook configuration')
.addTag('contacts', 'Contact management')
.addTag('groups', 'Group management')
.addTag('labels', 'Label management (WhatsApp Business)')
.addTag('channels', 'Channel/Newsletter management')
.addTag('health', 'Health check endpoints')
.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const configService = app.get(ConfigService);
const enableSwagger = configService.get<boolean>('security.enableSwagger', true);

if (enableSwagger) {
const config = new DocumentBuilder()
.setTitle('OpenWA API')
.setDescription('Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API')
.setVersion('0.1.6')
.addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'X-API-Key')
.addTag('sessions', 'WhatsApp session management')
.addTag('messages', 'Send and manage messages')
.addTag('webhooks', 'Webhook configuration')
.addTag('contacts', 'Contact management')
.addTag('groups', 'Group management')
.addTag('labels', 'Label management (WhatsApp Business)')
.addTag('channels', 'Channel/Newsletter management')
.addTag('health', 'Health check endpoints')
.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}

const port = process.env.PORT || 2785;
await app.listen(port);

console.log(`🚀 OpenWA is running on: http://localhost:${port}`);
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`);
if (enableSwagger) {
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`);
}
}

void bootstrap();
51 changes: 41 additions & 10 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, UnauthorizedException, OnModuleInit } fr
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash, randomBytes } from 'crypto';
import { secureCompare } from '../../common/security/secure-compare.util';
import { existsSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { ApiKey, ApiKeyRole } from './entities/api-key.entity';
Expand All @@ -26,18 +27,23 @@ export class AuthService implements OnModuleInit {
let isNewKey = false;

if (count === 0) {
// Use predictable key in development, random key in production
displayKey =
process.env.NODE_ENV === 'production' ? `owa_k1_${randomBytes(32).toString('hex')}` : 'dev-admin-key';

await this.seedApiKey(displayKey, 'Default Admin Key', ApiKeyRole.ADMIN);
const masterKey = process.env.API_MASTER_KEY?.trim();
if (masterKey) {
displayKey = masterKey;
await this.seedApiKey(masterKey, 'Master API Key (from API_MASTER_KEY)', ApiKeyRole.ADMIN);
} else {
displayKey =
process.env.NODE_ENV === 'production' ? `owa_k1_${randomBytes(32).toString('hex')}` : 'dev-admin-key';
await this.seedApiKey(displayKey, 'Default Admin Key', ApiKeyRole.ADMIN);
}
isNewKey = true;

// Save raw key to file for startup script to read
try {
writeFileSync(API_KEY_FILE, displayKey, 'utf-8');
} catch (err) {
this.logger.warn('Could not save API key file', { error: String(err) });
if (process.env.NODE_ENV !== 'production') {
try {
writeFileSync(API_KEY_FILE, displayKey, 'utf-8');
} catch (err) {
this.logger.warn('Could not save API key file', { error: String(err) });
}
}
} else {
// Read saved API key from file if exists
Expand Down Expand Up @@ -158,6 +164,11 @@ export class AuthService implements OnModuleInit {
}

async validateApiKey(rawKey: string, clientIp?: string, sessionId?: string): Promise<ApiKey> {
const masterKey = process.env.API_MASTER_KEY?.trim();
if (masterKey && secureCompare(rawKey, masterKey)) {
return this.buildMasterKeyPrincipal();
}

const keyHash = this.hashKey(rawKey);
const apiKey = await this.apiKeyRepository.findOne({ where: { keyHash } });

Expand Down Expand Up @@ -203,6 +214,26 @@ export class AuthService implements OnModuleInit {
return createHash('sha256').update(rawKey).digest('hex');
}

/** In-memory principal for API_MASTER_KEY (not persisted). */
private buildMasterKeyPrincipal(): ApiKey {
const now = new Date();
return {
id: 'api-master-key',
name: 'API Master Key',
keyHash: '',
keyPrefix: 'master',
role: ApiKeyRole.ADMIN,
allowedIps: null,
allowedSessions: null,
isActive: true,
expiresAt: null,
lastUsedAt: now,
usageCount: 0,
createdAt: now,
updatedAt: now,
} as ApiKey;
}

private isIpAllowed(clientIp: string, allowedIps: string[]): boolean {
// Phase 3 Security Audit: Support both exact match and CIDR notation
for (const entry of allowedIps) {
Expand Down
2 changes: 2 additions & 0 deletions src/modules/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/auth.decorators';

interface HealthCheckResult {
Expand All @@ -12,6 +13,7 @@ interface HealthCheckResult {
@ApiTags('health')
@Controller('health')
@Public()
@SkipThrottle()
export class HealthController {
@Get()
@ApiOperation({ summary: 'Basic health check' })
Expand Down
15 changes: 14 additions & 1 deletion src/modules/webhook/dto/webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsUrl, IsArray, IsOptional, IsBoolean, IsInt, Min, Max, ArrayMinSize } from 'class-validator';
import {
IsString,
IsUrl,
IsArray,
IsOptional,
IsBoolean,
IsInt,
Min,
Max,
MinLength,
ArrayMinSize,
} from 'class-validator';

export const WEBHOOK_EVENTS = [
'message.received',
Expand Down Expand Up @@ -41,6 +52,7 @@ export class CreateWebhookDto {
})
@IsOptional()
@IsString()
@MinLength(32)
secret?: string;

@ApiPropertyOptional({
Expand Down Expand Up @@ -77,6 +89,7 @@ export class UpdateWebhookDto {
@ApiPropertyOptional({ description: 'Secret key for HMAC signature' })
@IsOptional()
@IsString()
@MinLength(32)
secret?: string;

@ApiPropertyOptional({ description: 'Custom headers' })
Expand Down
Loading