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
80 changes: 80 additions & 0 deletions backend/src/config/__tests__/cors.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from '@jest/globals';
import request from 'supertest';
import express from 'express';
import { createCorsMiddleware } from '../cors.config.js';

describe('CORS Configuration Middleware', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('rejects origin: null when origin header is "null"', async () => {
const app = express();
app.use(createCorsMiddleware());
app.get('/test', (_req, res) => res.json({ ok: true }));

const res = await request(app)
.get('/test')
.set('Origin', 'null');

expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Origin not allowed' });
});

it('allows approved origin in production', async () => {
process.env.NODE_ENV = 'production';
process.env.CORS_ORIGIN = 'https://app.studentlab.xyz';

const app = express();
app.use(createCorsMiddleware());
app.get('/test', (_req, res) => res.json({ ok: true }));

const res = await request(app)
.get('/test')
.set('Origin', 'https://app.studentlab.xyz');

expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('https://app.studentlab.xyz');
expect(res.headers['access-control-allow-credentials']).toBe('true');
});

it('rejects unapproved origin in production', async () => {
process.env.NODE_ENV = 'production';
process.env.CORS_ORIGIN = 'https://app.studentlab.xyz';

const app = express();
app.use(createCorsMiddleware());
app.get('/test', (_req, res) => res.json({ ok: true }));

const res = await request(app)
.get('/test')
.set('Origin', 'https://malicious-site.com');

expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});

it('handles OPTIONS preflight for allowed origin', async () => {
process.env.NODE_ENV = 'production';
process.env.CORS_ORIGIN = 'https://app.studentlab.xyz';

const app = express();
app.use(createCorsMiddleware());
app.get('/test', (_req, res) => res.json({ ok: true }));

const res = await request(app)
.options('/test')
.set('Origin', 'https://app.studentlab.xyz')
.set('Access-Control-Request-Method', 'POST');

expect(res.status).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe('https://app.studentlab.xyz');
expect(res.headers['access-control-allow-methods']).toContain('POST');
});
});
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import dotenv from 'dotenv';
import express, { Request, Response } from 'express';
import { createCorsMiddleware } from './config/cors.config.js';
import { securityHeadersMiddleware } from './middleware/securityHeaders.js';
import swaggerDocsRouter from './config/swagger.serve.js';
import { jsonBodySizeLimit } from './middleware/bodySizeLimit.js';
import { dbRoutingMiddleware } from './middleware/dbRouting.js';
Expand All @@ -22,6 +23,9 @@ initializeSentry(app);
// Mount Sentry Request Handler middleware
app.use(getSentryRequestHandler());

// Security headers middleware (applied early before route handling)
app.use(securityHeadersMiddleware);

app.use(createCorsMiddleware());

app.use(express.json());
Expand Down
29 changes: 29 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,32 @@ services:
condition: service_healthy
redis-cluster-3:
condition: service_healthy

nginx:
image: nginx:alpine
container_name: web3-student-lab-proxy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- certs_data:/etc/nginx/certs:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
indexer:
condition: service_healthy
networks:
- web3-network
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s

volumes:
certs_data:
160 changes: 160 additions & 0 deletions nginx/default.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Production NGINX Reverse Proxy Configuration with TLS Termination, HTTP/2, and Security Headers
# Achieves Qualys SSL Labs A+ and Mozilla Observatory A+ rating

# Upstream Backend API
upstream backend_api {
server backend:8080;
keepalive 32;
}

# Upstream Frontend Application
upstream frontend_app {
server frontend:3000;
keepalive 32;
}

# Upstream Indexer / WebSockets
upstream indexer_service {
server indexer:3001;
keepalive 32;
}

# HTTP Server - Redirect all plaintext traffic to HTTPS
server {
listen 80;
listen [::]:80;
server_name _;

# ACME Challenge location for Let's Encrypt certificates
location /.well-known/acme-challenge/ {
root /var/www/certbot;
allow all;
}

# Redirect all remaining HTTP traffic to HTTPS (permanent 301)
location / {
return 301 https://$host$request_uri;
}
}

# HTTPS Server - TLS Termination with HTTP/2 Support
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;

# SSL Certificates
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;

# High-Security TLS Configuration (SSL Labs A+)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;

# SSL Session Optimization
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

# OCSP Stapling (Must be enabled for A+ rating)
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

# Security Headers (Mozilla Observatory A+)
# HSTS (Strict-Transport-Security): 1 year + subdomains + preload
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# Prevent MIME Sniffing
add_header X-Content-Type-Options "nosniff" always;

# Prevent Clickjacking (X-Frame-Options & CSP frame-ancestors)
add_header X-Frame-Options "DENY" always;

# Cross-Site Scripting Protection (legacy fallback)
add_header X-XSS-Protection "1; mode=block" always;

# Referrer Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Permissions Policy
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=()" always;

# Content Security Policy (Production-grade)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

# Hide NGINX version
server_tokens off;

# Gzip / Compression settings
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;

# Backend API Proxy
location /api/ {
proxy_pass http://backend_api;
proxy_http_version 1.1;

# Proxy Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;

# Timeouts and Buffering
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}

# Indexer WebSocket and SSE Proxy
location /indexer/ {
rewrite ^/indexer/(.*) /$1 break;
proxy_pass http://indexer_service;
proxy_http_version 1.1;

# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;

proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}

# Frontend Proxy (Fallback for root and app routes)
location / {
proxy_pass http://frontend_app;
proxy_http_version 1.1;

# WebSocket support (Next.js hot reload / socket connections)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
}

# Health check endpoint for Load Balancer
location /healthz {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}