diff --git a/apps/app-frontend/next.config.ts b/apps/app-frontend/next.config.ts index 683791bb..771cd563 100644 --- a/apps/app-frontend/next.config.ts +++ b/apps/app-frontend/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next'; +import { requireHttpsInProduction } from './src/lib/url-validation'; // Validate required environment variables at server startup only. // Skipped when SKIP_ENV_VALIDATION=1 (set in the Dockerfile builder stage) @@ -18,6 +19,11 @@ if (process.env.NODE_ENV === 'production' && !process.env.SKIP_ENV_VALIDATION) { 'The application cannot start until all required configuration is provided.', ); } + + // Enforce HTTPS for all public/browser-facing variables in production. + // NEXT_PUBLIC_* variables are embedded into the client bundle and may be + // used directly by browsers, so they must never use plain HTTP in production. + requireHttpsInProduction('NEXT_PUBLIC_API_URL', process.env.NEXT_PUBLIC_API_URL!); } const nextConfig: NextConfig = { diff --git a/apps/app-frontend/src/lib/url-validation.ts b/apps/app-frontend/src/lib/url-validation.ts new file mode 100644 index 00000000..a8b158a5 --- /dev/null +++ b/apps/app-frontend/src/lib/url-validation.ts @@ -0,0 +1,28 @@ +/** + * Throws if running in production and the supplied URL uses the insecure + * `http://` scheme. + * + * All public/browser-facing URLs must use HTTPS in production. + * Docker-internal service URLs (e.g. `http://core-api:5000`) are server-only + * and must NOT be validated with this helper — they communicate over private + * Docker networks and do not traverse a public network boundary. + * + * Local development may continue to use `http://localhost` URLs. + * + * @param name Environment variable name, used in error messages. + * @param value The URL value to validate. + * @returns The original value unchanged when the check passes. + * + * @throws Error in production if `value` starts with `http://`. + */ +export function requireHttpsInProduction(name: string, value: string): string { + if (process.env.NODE_ENV === 'production' && value.startsWith('http://')) { + throw new Error( + `${name} must use HTTPS in production. ` + + `Received a value starting with 'http://'. ` + + 'Set the environment variable to an https:// URL before starting the application.', + ); + } + + return value; +} diff --git a/apps/app-frontend/tests/unit/lib/url-validation.test.ts b/apps/app-frontend/tests/unit/lib/url-validation.test.ts new file mode 100644 index 00000000..696e8bd3 --- /dev/null +++ b/apps/app-frontend/tests/unit/lib/url-validation.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { requireHttpsInProduction } from '@/lib/url-validation'; + +// Reset NODE_ENV after each test to avoid pollution +const originalNodeEnv = process.env.NODE_ENV; +afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; +}); + +describe('requireHttpsInProduction', () => { + describe('in production (NODE_ENV=production)', () => { + it('throws when the URL starts with http://', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'http://api.example.com'), + ).toThrow(/NEXT_PUBLIC_API_URL must use HTTPS in production/); + }); + + it('throws for http://localhost in production', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'http://localhost:5000'), + ).toThrow(/must use HTTPS in production/); + }); + + it('does not throw when the URL starts with https://', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'https://api.curvit.co.uk'), + ).not.toThrow(); + }); + + it('returns the URL unchanged when it uses HTTPS', () => { + process.env.NODE_ENV = 'production'; + const url = 'https://api.curvit.co.uk'; + expect(requireHttpsInProduction('NEXT_PUBLIC_API_URL', url)).toBe(url); + }); + + it('includes the variable name in the error message', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('MY_PUBLIC_VAR', 'http://example.com'), + ).toThrow(/MY_PUBLIC_VAR/); + }); + }); + + describe('outside production (NODE_ENV=development)', () => { + it('does not throw for http://localhost in development', () => { + process.env.NODE_ENV = 'development'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'http://localhost:5000'), + ).not.toThrow(); + }); + + it('returns the URL unchanged in development', () => { + process.env.NODE_ENV = 'development'; + const url = 'http://localhost:5000'; + expect(requireHttpsInProduction('NEXT_PUBLIC_API_URL', url)).toBe(url); + }); + + it('does not throw for https:// URLs in development', () => { + process.env.NODE_ENV = 'development'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'https://api.curvit.co.uk'), + ).not.toThrow(); + }); + }); + + describe('outside production (NODE_ENV=test)', () => { + it('does not throw for http:// URLs in test environment', () => { + process.env.NODE_ENV = 'test'; + expect(() => + requireHttpsInProduction('NEXT_PUBLIC_API_URL', 'http://localhost:5000'), + ).not.toThrow(); + }); + }); +}); diff --git a/apps/marketing-site/src/config.ts b/apps/marketing-site/src/config.ts index bbdf4c5b..093ef293 100644 --- a/apps/marketing-site/src/config.ts +++ b/apps/marketing-site/src/config.ts @@ -1,13 +1,21 @@ +import { requireHttpsInProduction } from './lib/url-validation'; + /** * Base URL of the app frontend. * Prefer the runtime env injected by Docker/Node, then fall back to Vite's build-time env. * Local development should never default to the production app host. */ -export const appUrl: string = +export const appUrl: string = requireHttpsInProduction( + 'PUBLIC_APP_URL', process.env.PUBLIC_APP_URL - ?? import.meta.env.PUBLIC_APP_URL - ?? 'https://app.curvit.local.co.uk'; + ?? import.meta.env.PUBLIC_APP_URL + ?? 'https://app.curvit.local.co.uk', +); +/** + * Internal (Docker-network) URL of the app frontend. + * Server-only — never exposed to browsers or injected into client-side code. + */ export const internalAppUrl: string = process.env.INTERNAL_APP_URL ?? import.meta.env.INTERNAL_APP_URL diff --git a/apps/marketing-site/src/lib/url-validation.ts b/apps/marketing-site/src/lib/url-validation.ts new file mode 100644 index 00000000..6e2edb27 --- /dev/null +++ b/apps/marketing-site/src/lib/url-validation.ts @@ -0,0 +1,38 @@ +/** + * Throws if running in a production Astro build/SSR environment and the + * supplied URL uses the insecure `http://` scheme. + * + * All public/browser-facing URLs must use HTTPS in production. + * Docker-internal service URLs (e.g. `http://app-frontend:3000`) are + * server-only and must NOT be validated with this helper — they communicate + * over private Docker networks and do not cross a public network boundary. + * + * Local development may continue to use `http://localhost` URLs. + * + * Production detection uses both `import.meta.env.PROD` (Astro's build-time + * flag, set to `true` for production builds and SSR deployments) and + * `process.env.NODE_ENV` (Node.js runtime flag). Both are checked because + * Astro may set `import.meta.env.PROD` during SSG builds before Node.js env + * vars are fully propagated, while `NODE_ENV` is authoritative at runtime. + * + * @param name Environment variable name, used in error messages. + * @param value The URL value to validate. + * @returns The original value unchanged when the check passes. + * + * @throws Error in production if `value` starts with `http://`. + */ +export function requireHttpsInProduction(name: string, value: string): string { + const isProduction = + (typeof import.meta !== 'undefined' && import.meta.env?.PROD === true) || + process.env.NODE_ENV === 'production'; + + if (isProduction && value.startsWith('http://')) { + throw new Error( + `${name} must use HTTPS in production. ` + + `Received a value starting with 'http://'. ` + + 'Set the environment variable to an https:// URL before starting the application.', + ); + } + + return value; +} diff --git a/apps/marketing-site/tests/unit/url-validation.test.ts b/apps/marketing-site/tests/unit/url-validation.test.ts new file mode 100644 index 00000000..71bd49f3 --- /dev/null +++ b/apps/marketing-site/tests/unit/url-validation.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { requireHttpsInProduction } from '../../src/lib/url-validation'; + +// Reset NODE_ENV after each test to avoid pollution +const originalNodeEnv = process.env.NODE_ENV; +afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; +}); + +describe('requireHttpsInProduction (marketing-site)', () => { + describe('in production (NODE_ENV=production)', () => { + it('throws when the URL starts with http://', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'http://app.example.com'), + ).toThrow(/PUBLIC_APP_URL must use HTTPS in production/); + }); + + it('throws for http://localhost in production', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'http://localhost:3000'), + ).toThrow(/must use HTTPS in production/); + }); + + it('does not throw when the URL starts with https://', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'https://app.curvit.co.uk'), + ).not.toThrow(); + }); + + it('returns the URL unchanged when it uses HTTPS', () => { + process.env.NODE_ENV = 'production'; + const url = 'https://app.curvit.co.uk'; + expect(requireHttpsInProduction('PUBLIC_APP_URL', url)).toBe(url); + }); + + it('includes the variable name in the error message', () => { + process.env.NODE_ENV = 'production'; + expect(() => + requireHttpsInProduction('MY_PUBLIC_URL', 'http://example.com'), + ).toThrow(/MY_PUBLIC_URL/); + }); + }); + + describe('outside production (NODE_ENV=development)', () => { + it('does not throw for http://localhost in development', () => { + process.env.NODE_ENV = 'development'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'http://localhost:3000'), + ).not.toThrow(); + }); + + it('returns the URL unchanged in development', () => { + process.env.NODE_ENV = 'development'; + const url = 'http://localhost:3000'; + expect(requireHttpsInProduction('PUBLIC_APP_URL', url)).toBe(url); + }); + + it('does not throw for https:// URLs in development', () => { + process.env.NODE_ENV = 'development'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'https://app.curvit.co.uk'), + ).not.toThrow(); + }); + }); + + describe('outside production (NODE_ENV=test)', () => { + it('does not throw for http:// URLs in test environment', () => { + process.env.NODE_ENV = 'test'; + expect(() => + requireHttpsInProduction('PUBLIC_APP_URL', 'http://localhost:3000'), + ).not.toThrow(); + }); + }); +});