From dae49bd4e1aaee133325d623906ea356afeff582 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:06:13 +0000 Subject: [PATCH 1/3] Initial plan From 322645191533d88f447b1ea3dab54ecea434fc82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:12:56 +0000 Subject: [PATCH 2/3] feat(security): enforce HTTPS for public/browser-facing URLs in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add requireHttpsInProduction helper to app-frontend and marketing-site - Validate NEXT_PUBLIC_API_URL uses HTTPS in production via next.config.ts - Validate PUBLIC_APP_URL uses HTTPS in production via marketing-site config.ts - Add unit tests for both URL validation helpers (18 tests total) All server-only Docker network URLs (http://core-api:5000, etc.) are correctly classified as safe — they communicate over private Docker networks and never reach a browser. Closes: S5332 SonarCloud hotspots for public-facing URLs" Agent-Logs-Url: https://github.com/NickLetts2/Curvit/sessions/d94c2413-0222-438c-8feb-754db10cd98d Co-authored-by: NickLetts2 <90337962+NickLetts2@users.noreply.github.com> --- apps/app-frontend/next.config.ts | 14 ++++ apps/app-frontend/src/lib/url-validation.ts | 28 +++++++ .../tests/unit/lib/url-validation.test.ts | 77 +++++++++++++++++++ apps/marketing-site/src/config.ts | 14 +++- apps/marketing-site/src/lib/url-validation.ts | 32 ++++++++ .../tests/unit/url-validation.test.ts | 77 +++++++++++++++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 apps/app-frontend/src/lib/url-validation.ts create mode 100644 apps/app-frontend/tests/unit/lib/url-validation.test.ts create mode 100644 apps/marketing-site/src/lib/url-validation.ts create mode 100644 apps/marketing-site/tests/unit/url-validation.test.ts diff --git a/apps/app-frontend/next.config.ts b/apps/app-frontend/next.config.ts index 683791bb..62575e5b 100644 --- a/apps/app-frontend/next.config.ts +++ b/apps/app-frontend/next.config.ts @@ -18,6 +18,20 @@ 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 URLs in production. + // NEXT_PUBLIC_* variables are embedded into the client bundle and may be + // sent to browsers, so they must never use plain HTTP in production. + const publicHttpVars = ['NEXT_PUBLIC_API_URL'].filter( + (key) => process.env[key]?.startsWith('http://'), + ); + if (publicHttpVars.length > 0) { + throw new Error( + `The following public environment variables must use HTTPS in production: ${publicHttpVars.join(', ')}. ` + + "Received a value starting with 'http://'. " + + 'Update the environment variable to an https:// URL before starting the application.', + ); + } } 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..7d9e03d8 --- /dev/null +++ b/apps/marketing-site/src/lib/url-validation.ts @@ -0,0 +1,32 @@ +/** + * 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. + * + * @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(); + }); + }); +}); From 67f220a12162f92cf659d16fe3f0debeb499ee33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:15:21 +0000 Subject: [PATCH 3/3] refactor(security): use requireHttpsInProduction in next.config.ts; document Astro dual-detection rationale Agent-Logs-Url: https://github.com/NickLetts2/Curvit/sessions/d94c2413-0222-438c-8feb-754db10cd98d Co-authored-by: NickLetts2 <90337962+NickLetts2@users.noreply.github.com> --- apps/app-frontend/next.config.ts | 16 ++++------------ apps/marketing-site/src/lib/url-validation.ts | 6 ++++++ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/app-frontend/next.config.ts b/apps/app-frontend/next.config.ts index 62575e5b..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) @@ -19,19 +20,10 @@ if (process.env.NODE_ENV === 'production' && !process.env.SKIP_ENV_VALIDATION) { ); } - // Enforce HTTPS for all public/browser-facing URLs in production. + // Enforce HTTPS for all public/browser-facing variables in production. // NEXT_PUBLIC_* variables are embedded into the client bundle and may be - // sent to browsers, so they must never use plain HTTP in production. - const publicHttpVars = ['NEXT_PUBLIC_API_URL'].filter( - (key) => process.env[key]?.startsWith('http://'), - ); - if (publicHttpVars.length > 0) { - throw new Error( - `The following public environment variables must use HTTPS in production: ${publicHttpVars.join(', ')}. ` + - "Received a value starting with 'http://'. " + - 'Update the environment variable to an https:// URL before starting the application.', - ); - } + // 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/marketing-site/src/lib/url-validation.ts b/apps/marketing-site/src/lib/url-validation.ts index 7d9e03d8..6e2edb27 100644 --- a/apps/marketing-site/src/lib/url-validation.ts +++ b/apps/marketing-site/src/lib/url-validation.ts @@ -9,6 +9,12 @@ * * 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.