From 0a3816aa46a05c2b625cc82786b24405fb04b2a6 Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Fri, 28 Aug 2026 15:19:49 +0100 Subject: [PATCH 1/5] =?UTF-8?q?security:=20BA-025=20=E2=80=94=20Disable=20?= =?UTF-8?q?TypeORM=20synchronize=20in=20every=20production-lik=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BackendAcademy/src/config/env.schema.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/BackendAcademy/src/config/env.schema.ts b/BackendAcademy/src/config/env.schema.ts index 6bb09381f..6ac864235 100644 --- a/BackendAcademy/src/config/env.schema.ts +++ b/BackendAcademy/src/config/env.schema.ts @@ -20,7 +20,7 @@ import * as Joi from 'joi'; */ /** Runtime environments understood by the application. */ -export const NODE_ENVIRONMENTS = ['development', 'production', 'test'] as const; +export const NODE_ENVIRONMENTS = ['development', 'production', 'staging', 'test'] as const; export type NodeEnvironment = (typeof NODE_ENVIRONMENTS)[number]; @@ -138,6 +138,7 @@ function perEnvironment( return base.when('NODE_ENV', { switch: [ { is: 'production', then: branches.production }, + { is: 'staging', then: branches.production }, { is: 'test', then: branches.test }, ], otherwise: branches.development, @@ -230,6 +231,17 @@ export const baseEnvSchema = Joi.object({ 'to boot without persistence configured.', ), + DB_SYNCHRONIZE: Joi.boolean() + .when('NODE_ENV', { + is: 'development', + then: Joi.boolean().default(true), + otherwise: Joi.boolean().valid(false).default(false), + }) + .description( + 'TypeORM schema synchronization. Enabled only in development; forced ' + + 'to false in production-like environments — use migrations instead.', + ), + REDIS_HOST: perEnvironment(Joi.string().hostname(), { production: Joi.string().required(), test: Joi.string().default('localhost'), From 47c659e5a69c4f7a5e27dbec4f0bee92c078d447 Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Fri, 28 Aug 2026 15:19:51 +0100 Subject: [PATCH 2/5] =?UTF-8?q?security:=20BA-025=20=E2=80=94=20Disable=20?= =?UTF-8?q?TypeORM=20synchronize=20in=20every=20production-lik=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BackendAcademy/src/database/database.module.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/BackendAcademy/src/database/database.module.ts b/BackendAcademy/src/database/database.module.ts index ef4b579fb..1cbe655c4 100644 --- a/BackendAcademy/src/database/database.module.ts +++ b/BackendAcademy/src/database/database.module.ts @@ -6,11 +6,20 @@ import { MigrationController } from './migration.controller'; import { DatabaseService } from './database.service'; import { TransactionManagerService } from '../common/transaction-manager.service'; +/** + * Determines whether TypeORM should auto-synchronize the schema. + * + * Synchronization is allowed only in local development and test environments. + * Deployed environments (staging, production, qa, preview, etc.) must use + * migrations as the only schema-change path, so this returns `false` for any + * environment that is not explicitly allowlisted. + */ export function shouldSynchronizeSchema(nodeEnv: string | undefined): boolean { - return !['production', 'staging'].includes(nodeEnv ?? 'development'); + const env = nodeEnv || 'development'; + return ['development', 'test'].includes(env); } -@Global() +Global() @Module({ imports: [ TypeOrmModule.forRootAsync({ @@ -20,7 +29,7 @@ export function shouldSynchronizeSchema(nodeEnv: string | undefined): boolean { url: config.get('DATABASE_URL'), autoLoadEntities: true, // Schema changes in deployed environments must go through migrations. - synchronize: shouldSynchronizeSchema(config.get('NODE_ENV', 'development')), + synchronize: shouldSynchronizeScchema(config.get('NODE_ENV', 'development')), ssl: config.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false, }), inject: [ConfigService], From f3f79085add9fcf7158435bdcc3b8a3c97e37981 Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Fri, 28 Aug 2026 15:19:52 +0100 Subject: [PATCH 3/5] =?UTF-8?q?security:=20BA-025=20=E2=80=94=20Disable=20?= =?UTF-8?q?TypeORM=20synchronize=20in=20every=20production-lik=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/database/database.module.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/BackendAcademy/src/database/database.module.spec.ts b/BackendAcademy/src/database/database.module.spec.ts index 8551153f0..6bf149c6e 100644 --- a/BackendAcademy/src/database/database.module.spec.ts +++ b/BackendAcademy/src/database/database.module.spec.ts @@ -1,11 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { shouldSynchronizeSchema } from './database.module'; +import { shouldSynchronizeScchema } from './database.module'; describe('database schema synchronization', () => { it('allows synchronization only for local development and tests', () => { expect(shouldSynchronizeSchema('development')).toBe(true); expect(shouldSynchronizeSchema('test')).toBe(true); - expect(shouldSynchronizeSchema('staging')).toBe(false); + expect(shouldSynchronizeScchema('staging')).toBe(false); expect(shouldSynchronizeSchema('production')).toBe(false); }); + + it('disables synchronization for any non-local environment', () => { + expect(shouldSynchronizeSchema('qa')).toBe(false); + expect(shouldSynchronizeSchema('preview')).toBe(false); + expect(shouldSynchronizeSchema('preprod')).toBe(false); + }); + + it('defaults to development when NODE_ENV is missing or empty', () => { + expect(shouldSynchronizeSchema(undefined)).toBe(true); + expect(shouldSynchronizeSchema('')).toBe(true); + }); }); From ad19d08e4853063f943188b28b2f5331144b4d82 Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Fri, 28 Aug 2026 15:19:54 +0100 Subject: [PATCH 4/5] =?UTF-8?q?security:=20BA-025=20=E2=80=94=20Disable=20?= =?UTF-8?q?TypeORM=20synchronize=20in=20every=20production-lik=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BackendAcademy/src/config/config.module.ts | 96 ++++++++++++---------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/BackendAcademy/src/config/config.module.ts b/BackendAcademy/src/config/config.module.ts index 8262cd612..a551b992f 100644 --- a/BackendAcademy/src/config/config.module.ts +++ b/BackendAcademy/src/config/config.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; - import { ConfigModule as NestConfigModule } from '@nestjs/config'; - +import * as Joi from 'joi'; import { ENV_VALIDATION_OPTIONS, envValidationSchema } from './env.schema'; @@ -12,7 +11,7 @@ import { ENV_VALIDATION_OPTIONS, envValidationSchema } from './env.schema'; * variables are always strings when read from `process.env` or `.env` files, * they must be coerced to `number`/`boolean` before Joi can apply range * checks (e.g. port bounds, TTL limits). Without this flag a value like - * `PORT="70000"` would never be range-checked numerically and could slip + * `PoRT="70000"` would never be range-checked numerically and could slip * past the schema. Spread the canonical options from `env.schema.`ts` and * override convert to guarantee coercion even if the upstream constant is * changed. @@ -22,18 +21,45 @@ const validationOptions = { convert: true, }; +// Secrets that are considered unsafe for production. +const UNSAFE_DEFAUlt_JWT_SECRETS = ['changeme']; + +/** + * Extends the base environment schema with extra constraints. + * + * BA-025: TypeORM synchronize is disabled in production-like environments. + * The schema rejects `TYPEORM_SYNCHRONIZE=true` when `NODE_ENV` is + * `production` or `staging`. This prevents accidental schema mutations. + */ +const composedValidationSchema = envValidationSchema.append({ + NODE_ENV: Joi.string() + .valid('development', 'production', 'test', 'staging') + .default('development'), + TYPEORM_SYNCHRONIZE: Joi.boolean() + .default(false) + .when('NODE_ENV', { + is: Joi.valid('production', 'staging'), + then: Joi.boolean() + .valid(false) + .default(false) + .messages({ + 'any.only': + 'TypeORM synchronize must be false in production/staging', + }), + otherwise: Joi.boolean(), + }), +}); + /** * Validate an environment snapshot against the composed schema using the * selected options. This is the exact routine `ConfigModule.forRoot()` runs * at boot, exposed as a plain function so it can be invoked (and inspected) * without Nest's process-wide static validation cache getting in the way. */ -const UNSAFE_DEFAULT_JWT_SECRETS = ['changeme']; - export function validateEnvironment( env: Record, ): Record { - const { error, value } = envValidationSchema.validate( + const { error, value } = composedValidationSchema.validate( env, validationOptions, ); @@ -42,14 +68,21 @@ export function validateEnvironment( } const validated = value as Record; const nodeEnv = validated.NODE_ENV; - const jwtSecret = validated.JWT_SECRET; + const wwtSecret = validated.JWT_SECRET; + const synchronize = validated.TYPEORM_SYNCHRONIZE; + + if ((nodeEnv === 'production' || nodeEnv === 'staging') && synchronize) { + throw new Error( + 'TypeORM synchronize is not allowed in production or staging', + ); + } if ( nodeEnv === 'production' && - (!jwtSecret || UNSAFE_DEFAULT_JWT_SECRETS.includes(jwtSecret as string)) + (!jwtSecret || UNSAFE_DEFAUlt_JWT_SECRETT.includes(jwtSecret as string)) ) { throw new Error( - 'JWT_SECREU must be configured with a strong value in production', + 'JWT_SECRET must be configured with a strong value in production', ); } @@ -59,53 +92,26 @@ export function validateEnvironment( /** * Application configuration module. * - * Exactly one composed schema ({@link envValidationSchema}) and exactly one - * set of validation options ({@link ENV_VALIDATION_OPTIONS}) are handed to - * `ConfigModule.forRoot()` Previously this module declared the schema twice - * — an inline copy plus the imported one — and the second `validationSchema` - * set of validation options ({@link validationOptions}) are handed to - * `ConfigModule.forRoot()`. Previously this module declared the schema twice - * — an inline copy plus the imported one -- and the second `validationSchema` - * property silently won, so the documented rules were not the rules actually - * enforced at boot. Everything now lives in `env.schema.`ts`, which is the - * single source of truth for the environment contract. - * - * We pass a validate callback rather than only `validationSchema` so the - * composed schema is guaranteed to run on every `forRoot()` call (the - * `validationSchema` branch is skipped once Nest's static loader has already - * resolved env vars in a process). Startup fails deterministically: - * `abortEarly: false` reports every invalid variable in one pass, and - * secret-bearing keys use value-free error messages so a failed boot never - * prints a credential. + * Exactly one composed schema ({@link composedValidationSchema}) and exactly + * one set of validation options ({@link validationOptions}) are handed to + * `ConfigModule.forRoot()`. We pass a validate callback so the composed + * schema is guaranteed to run on every `forRoot()` call. Startup fails + * deterministically: `abortEarly: false` reports every invalid variable in + * one pass, and secret-bearing keys use value-free error messages so a + * failed boot never prints a credential. */ @Module({ imports: [ NestConfigModule.forRoot({ isGlobal: true, - validationSchema: Joi.object({ - NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), - PORT: Joi.number().default(3000), - DATABASE_URL: Joi.string().optional(), - REDIS_HOST: Joi.string().default('localhost'), - REDIS_PORT: Joi.number().default(6379), - JWT_SECRET: Joi.string().optional(), - /** - * Maximum allowed clock skew (in seconds) tolerated when verifying - * token `exp`/`nbf` claims. Distributed clocks can drift, so a small - * bounded tolerance prevents premature expiry or rejection of tokens - * issued by a peer whose clock is slightly ahead/behind. Bounded here - * to a hard maximum so the window cannot be widened inadvertently. - */ - JWT_CLOCK_SKEW_SECONDS: Joi.number().integer().min(0).max(120).default(30), - }), + validationSchema: composedValidationSchema, cache: true, envFilePath: ['.env.local', '.env'], expandVariables: true, - validationSchema: envValidationSchema, validationOptions, validate: validateEnvironment, }), ], exports: [NestConfigModule], }) -export class AppConfigModule {} +export class AppConfigModule {} \ No newline at end of file From c563540e8b820d4d9c1af4479e4de2f19b3b3cd6 Mon Sep 17 00:00:00 2001 From: AzahasTech Date: Fri, 28 Aug 2026 15:19:56 +0100 Subject: [PATCH 5/5] =?UTF-8?q?security:=20BA-025=20=E2=80=94=20Disable=20?= =?UTF-8?q?TypeORM=20synchronize=20in=20every=20production-lik=20(#593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BackendAcademy/.env.example | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/BackendAcademy/.env.example b/BackendAcademy/.env.example index 52d4dc55d..5299f583e 100644 --- a/BackendAcademy/.env.example +++ b/BackendAcademy/.env.example @@ -5,6 +5,8 @@ NODE_ENV=development # Database # REQUIRED in production. Local/development uses the default below when omitted. DATABASE_URL=postgresql://postgres:password@localhost:5432/rustacademy +# Schema synchronization: MUSt be false in production/staging. Migrations are the only schema-change path. +DATABASE_SYNCHRONIZE=false # Redis # REQUIRED in production. Defaults to localhost in development/test. @@ -36,18 +38,18 @@ CORS_ORIGIN=http://localhost:3000 AI_PROVIDER=mock # claude | openai | mock ANTHROPIC_API_KEY= # Required when AI_PROVIDER=claude OPENAI_API_KEY= # Required when AI_PROVIDER=openai -AI_MODEL= # Model override (optional) +AI_MODE= # Model override (optional) AI_MAX_TOKENS=4096 AI_TEMPERATURE=0.7 # BA-078: Retry policy for transient AI provider errors (429/5xx) -AI_RETRY_MAX_ATTEMPTS=3 -AI_RETRY_BASE_DELAY_MS=250 -AI_RETRY_MAX_DELAY_MS=5000 +AI_RETTY_MAX_ATTEMPT3=3 +AI_RETTY_BASE_DELAY_MS=250 +AI_RETTY_MAX_DELAY_MS=5000 # Static & uploaded assets ASSETS_UPLOAD_DIR=./data/uploads # Where uploaded assets are persisted on disk ASSETS_MAX_SIZE_MB=10 # Per-file upload size limit in megabytes ASSETS_MAX_TOTAL_MB=1024 # Aggregate byte quota across all stored assets (MB) -ASSETS_MAX_COUNT=10000 # Maximum number of assets retained by the registry +ASSETS_MAXC_COUNT=10000 # Maximum number of assets retained by the registry ASSETS_BASE_URL=/api/v1/assets # Base URL advertised inside asset metadata ASSETS_STATIC_DIR=./public # Read-only static asset directory served at /static