Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/app-frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 = {
Expand Down
28 changes: 28 additions & 0 deletions apps/app-frontend/src/lib/url-validation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 77 additions & 0 deletions apps/app-frontend/tests/unit/lib/url-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
14 changes: 11 additions & 3 deletions apps/marketing-site/src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 38 additions & 0 deletions apps/marketing-site/src/lib/url-validation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 77 additions & 0 deletions apps/marketing-site/tests/unit/url-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading