diff --git a/apps/app-frontend/playwright.config.ts b/apps/app-frontend/playwright.config.ts index d1e0d7ca..744df95e 100644 --- a/apps/app-frontend/playwright.config.ts +++ b/apps/app-frontend/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test'; +// All http://127.0.0.1:41017 URLs in this file are loopback addresses used +// exclusively for Playwright E2E tests. Not used in production traffic. +// See docs/security/internal-networking.md. + const isRealAuthE2E = process.env.REAL_AUTH_E2E === '1'; export default defineConfig({ @@ -9,7 +13,7 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : [['html', { open: 'never' }], ['list']], use: { - baseURL: 'http://127.0.0.1:41017', + baseURL: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL; not used in production. ignoreHTTPSErrors: true, screenshot: 'only-on-failure', trace: 'on-first-retry', @@ -33,7 +37,7 @@ export default defineConfig({ // First run: npm run build to create .next/ directory // Then: next start serves from the prebuilt bundle command: 'node scripts/e2e-server.mjs', - url: 'http://127.0.0.1:41017', + url: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL. reuseExistingServer: !process.env.CI, timeout: 360_000, ignoreHTTPSErrors: true, @@ -49,14 +53,14 @@ export default defineConfig({ // be set. In CI this is overridden by the workflow environment variable. // next-auth requires AUTH_SECRET to be at least 32 characters. AUTH_SECRET: process.env.AUTH_SECRET ?? 'e2e-local-dummy-secret-minimum-32-chars-required-for-nextauth', - AUTH_URL: 'http://127.0.0.1:41017', - NEXTAUTH_URL: 'http://127.0.0.1:41017', + AUTH_URL: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL. + NEXTAUTH_URL: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL. AUTH_TRUST_HOST: 'true', - INTERNAL_API_URL: 'http://127.0.0.1:41017', - NEXT_PUBLIC_API_URL: 'http://127.0.0.1:41017', + INTERNAL_API_URL: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL; mocks core-api on loopback. + NEXT_PUBLIC_API_URL: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test loopback URL; not exposed to browsers in production. AUTH_AUTHENTIK_ISSUER: isRealAuthE2E ? process.env.AUTH_AUTHENTIK_ISSUER ?? 'https://auth.curvit.local.co.uk/application/o/curvit/' - : 'http://127.0.0.1:41017/application/o/curvit/', + : 'http://127.0.0.1:41017/application/o/curvit/', // NOSONAR: S5332 - E2E test loopback issuer; only used when isRealAuthE2E is false. DISABLE_URL_REWRITES: '0', PLAYWRIGHT_E2E: isRealAuthE2E ? '0' : '1', // Redirect unauthenticated middleware to the local login page rather than @@ -66,7 +70,7 @@ export default defineConfig({ // ('networkidle') to time-out and making redirect tests flaky. MARKETING_URL: isRealAuthE2E ? process.env.MARKETING_URL ?? 'https://curvit.local.co.uk' - : 'http://127.0.0.1:41017/login', + : 'http://127.0.0.1:41017/login', // NOSONAR: S5332 - E2E test loopback URL; only used when isRealAuthE2E is false. }, }, }); diff --git a/apps/app-frontend/src/app/api/e2e/auth/route.ts b/apps/app-frontend/src/app/api/e2e/auth/route.ts index 09efd5b5..021c13a3 100644 --- a/apps/app-frontend/src/app/api/e2e/auth/route.ts +++ b/apps/app-frontend/src/app/api/e2e/auth/route.ts @@ -38,7 +38,7 @@ function hashPassword(password: string) { } function redirectTo(path: string) { - return NextResponse.redirect(new URL(path, process.env.NEXTAUTH_URL ?? 'http://127.0.0.1:41017')); + return NextResponse.redirect(new URL(path, process.env.NEXTAUTH_URL ?? 'http://127.0.0.1:41017')); // NOSONAR: S5332 - E2E test route; only reachable during Playwright tests on loopback. } function readRegisteredState(cookieHeader: string) { diff --git a/apps/app-frontend/src/lib/api/account.ts b/apps/app-frontend/src/lib/api/account.ts index fe2b1f8c..51881dfb 100644 --- a/apps/app-frontend/src/lib/api/account.ts +++ b/apps/app-frontend/src/lib/api/account.ts @@ -63,7 +63,7 @@ export async function exportAccountData( }); } - const baseUrl = process.env.CORE_API_URL ?? 'http://core-api:5000'; + const baseUrl = process.env.CORE_API_URL ?? 'http://core-api:5000'; // NOSONAR: S5332 - Internal Docker URL; server-side only. const res = await fetch(`${baseUrl}/api/v1/account/data-export`, { headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/apps/app-frontend/src/lib/api/admin.ts b/apps/app-frontend/src/lib/api/admin.ts index 1308b78a..6f583d13 100644 --- a/apps/app-frontend/src/lib/api/admin.ts +++ b/apps/app-frontend/src/lib/api/admin.ts @@ -133,7 +133,7 @@ class AdminServiceClient extends BaseServiceClient { super({ serviceName: 'admin-api', serverBaseUrlEnvVar: 'ADMIN_SERVICE_URL', - serverBaseUrlDefault: 'http://admin-service:8000', + serverBaseUrlDefault: 'http://admin-service:8000', // NOSONAR: S5332 - Internal Docker URL; server-side only. serverPathPrefix: '/admin', clientPathPrefix: '/api/v1/admin', }); @@ -145,7 +145,7 @@ class BillingAdminServiceClient extends BaseServiceClient { super({ serviceName: 'billing-admin-api', serverBaseUrlEnvVar: 'BILLING_SERVICE_URL', - serverBaseUrlDefault: 'http://billing-service:8000', + serverBaseUrlDefault: 'http://billing-service:8000', // NOSONAR: S5332 - Internal Docker URL; server-side only. serverPathPrefix: '/admin/billing', clientPathPrefix: '/api/v1/admin/billing', }); @@ -494,7 +494,7 @@ export async function adminExportUserData( userId: string, accessToken: string ): Promise { - const baseUrl = process.env.INTERNAL_API_URL ?? 'http://core-api:5000'; + const baseUrl = process.env.INTERNAL_API_URL ?? 'http://core-api:5000'; // NOSONAR: S5332 - Internal Docker URL; server-side only. const res = await fetch(`${baseUrl}/api/v1/admin/users/${userId}/data-export`, { headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/apps/app-frontend/src/lib/api/billing.ts b/apps/app-frontend/src/lib/api/billing.ts index 93c1c8b5..ae87cc42 100644 --- a/apps/app-frontend/src/lib/api/billing.ts +++ b/apps/app-frontend/src/lib/api/billing.ts @@ -35,7 +35,7 @@ type PortalWire = CreatePortalSessionResponse_v1 & { portal_url?: string }; function getBillingApiBaseUrl(): string { // Server-side: use billing-service directly if (typeof window === 'undefined') { - return process.env.BILLING_SERVICE_URL ?? 'http://billing-service:8000'; + return process.env.BILLING_SERVICE_URL ?? 'http://billing-service:8000'; // NOSONAR: S5332 - Internal Docker URL; server-side only. } // Client-side: use the gateway (will be prefixed with api.curvit.local.co.uk by apiRequest) return ''; @@ -119,7 +119,7 @@ export async function createCheckoutSession( accessToken: string ): Promise { if (isPlaywrightE2EEnabled()) { - return `http://127.0.0.1:41017/settings?checkout=success&tier=${tier}`; + return `http://127.0.0.1:41017/settings?checkout=success&tier=${tier}`; // NOSONAR: S5332 - E2E test-only URL; guarded by isPlaywrightE2EEnabled(). } const result = await billingRequest( @@ -139,7 +139,7 @@ export async function createPortalSession( accessToken: string ): Promise { if (isPlaywrightE2EEnabled()) { - return 'http://127.0.0.1:41017/settings'; + return 'http://127.0.0.1:41017/settings'; // NOSONAR: S5332 - E2E test-only URL; guarded by isPlaywrightE2EEnabled(). } const result = await billingRequest( diff --git a/apps/app-frontend/src/lib/api/blog.ts b/apps/app-frontend/src/lib/api/blog.ts index 57b27c3d..00a1d272 100644 --- a/apps/app-frontend/src/lib/api/blog.ts +++ b/apps/app-frontend/src/lib/api/blog.ts @@ -83,7 +83,7 @@ function toBlogPostPayload(payload: SaveBlogPostPayload) { function getCmsApiBaseUrl(): string { // Server-side: use cms-service directly if (typeof window === 'undefined') { - return process.env.CMS_SERVICE_URL ?? 'http://cms-service:8000'; + return process.env.CMS_SERVICE_URL ?? 'http://cms-service:8000'; // NOSONAR: S5332 - Internal Docker URL; server-side only. } // Client-side: use the gateway (will be prefixed with api.curvit.local.co.uk by apiRequest) return ''; diff --git a/apps/app-frontend/src/lib/api/health.ts b/apps/app-frontend/src/lib/api/health.ts index 6831505f..192d6f16 100644 --- a/apps/app-frontend/src/lib/api/health.ts +++ b/apps/app-frontend/src/lib/api/health.ts @@ -7,23 +7,24 @@ import type { ServiceHealthResult_v1 } from '@curvit/contracts'; // Stateful infrastructure without an HTTP health endpoint from the app network // (for example Postgres and Redis) remains covered by Docker health checks and // monitoring dashboards rather than this page. +// All http:// URLs below are internal Docker addresses. See docs/security/internal-networking.md. const SERVICES: { name: string; url: string }[] = [ - { name: 'App Frontend', url: 'http://app-frontend:3000/api/health' }, - { name: 'Marketing Site', url: 'http://marketing-site:8080/' }, - { name: 'Core API', url: 'http://core-api:5000/health' }, - { name: 'Admin Service', url: 'http://admin-service:8000/health' }, - { name: 'Billing Service', url: 'http://billing-service:8000/health' }, - { name: 'CMS Service', url: 'http://cms-service:8000/health' }, - { name: 'Messaging Service', url: 'http://messaging-service:8000/health' }, - { name: 'AI Orchestrator', url: 'http://ai-orchestrator:8000/health' }, - { name: 'CV Structuring Service', url: 'http://cv-structuring-service:8000/health' }, - { name: 'Content Sanitiser', url: 'http://content-sanitiser:8000/health' }, - { name: 'Output Validator', url: 'http://output-validator:8000/health' }, - { name: 'Document Renderer', url: 'http://document-renderer:8000/health' }, - { name: 'Document Ingestion', url: 'http://document-ingestion-service:8001/health' }, - { name: 'Analysis Worker', url: 'http://analysis-worker:8000/health' }, - { name: 'Batch Ranking Worker', url: 'http://batch-ranking-worker:8000/health' }, - { name: 'ClamAV REST', url: 'http://clamav-rest:8080/api/v1/version' }, + { name: 'App Frontend', url: 'http://app-frontend:3000/api/health' }, // NOSONAR: S5332 + { name: 'Marketing Site', url: 'http://marketing-site:8080/' }, // NOSONAR: S5332 + { name: 'Core API', url: 'http://core-api:5000/health' }, // NOSONAR: S5332 + { name: 'Admin Service', url: 'http://admin-service:8000/health' }, // NOSONAR: S5332 + { name: 'Billing Service', url: 'http://billing-service:8000/health' }, // NOSONAR: S5332 + { name: 'CMS Service', url: 'http://cms-service:8000/health' }, // NOSONAR: S5332 + { name: 'Messaging Service', url: 'http://messaging-service:8000/health' }, // NOSONAR: S5332 + { name: 'AI Orchestrator', url: 'http://ai-orchestrator:8000/health' }, // NOSONAR: S5332 + { name: 'CV Structuring Service', url: 'http://cv-structuring-service:8000/health' }, // NOSONAR: S5332 + { name: 'Content Sanitiser', url: 'http://content-sanitiser:8000/health' }, // NOSONAR: S5332 + { name: 'Output Validator', url: 'http://output-validator:8000/health' }, // NOSONAR: S5332 + { name: 'Document Renderer', url: 'http://document-renderer:8000/health' }, // NOSONAR: S5332 + { name: 'Document Ingestion', url: 'http://document-ingestion-service:8001/health' }, // NOSONAR: S5332 + { name: 'Analysis Worker', url: 'http://analysis-worker:8000/health' }, // NOSONAR: S5332 + { name: 'Batch Ranking Worker', url: 'http://batch-ranking-worker:8000/health' }, // NOSONAR: S5332 + { name: 'ClamAV REST', url: 'http://clamav-rest:8080/api/v1/version' }, // NOSONAR: S5332 ]; export async function checkServiceHealth(): Promise { diff --git a/apps/app-frontend/src/lib/api/internal-auth.ts b/apps/app-frontend/src/lib/api/internal-auth.ts index 8294df9b..766dd238 100644 --- a/apps/app-frontend/src/lib/api/internal-auth.ts +++ b/apps/app-frontend/src/lib/api/internal-auth.ts @@ -1,5 +1,5 @@ const getInternalApiBaseUrl = () => - process.env.INTERNAL_API_URL || process.env.CORE_API_URL || 'http://core-api:5000'; + process.env.INTERNAL_API_URL || process.env.CORE_API_URL || 'http://core-api:5000'; // NOSONAR: S5332 - Internal Docker URL; server-side only. See docs/security/internal-networking.md. function getInternalHeaders(): HeadersInit { const headers: HeadersInit = { diff --git a/apps/app-frontend/src/lib/api/messages.ts b/apps/app-frontend/src/lib/api/messages.ts index 3809c6cb..0b497c9a 100644 --- a/apps/app-frontend/src/lib/api/messages.ts +++ b/apps/app-frontend/src/lib/api/messages.ts @@ -184,7 +184,7 @@ class MessagingServiceClient extends BaseServiceClient { super({ serviceName: 'messaging-api', serverBaseUrlEnvVar: 'MESSAGING_SERVICE_URL', - serverBaseUrlDefault: 'http://messaging-service:8000', + serverBaseUrlDefault: 'http://messaging-service:8000', // NOSONAR: S5332 - Internal Docker URL; server-side only. serverPathPrefix: '', clientPathPrefix: '/api/v1', }); diff --git a/apps/app-frontend/src/lib/auth/logoutCookies.ts b/apps/app-frontend/src/lib/auth/logoutCookies.ts index 715e383e..af0a6a8b 100644 --- a/apps/app-frontend/src/lib/auth/logoutCookies.ts +++ b/apps/app-frontend/src/lib/auth/logoutCookies.ts @@ -34,7 +34,7 @@ export function getSharedCookieDomain(marketingUrl?: string) { export function buildMarketingLogoutUrl(marketingUrl?: string) { if (process.env.PLAYWRIGHT_E2E === '1') { - return new URL('/login?loggedOut=1', marketingUrl ?? 'http://127.0.0.1:41017').toString(); + return new URL('/login?loggedOut=1', marketingUrl ?? 'http://127.0.0.1:41017').toString(); // NOSONAR: S5332 - E2E test-only fallback; guarded by PLAYWRIGHT_E2E check. } return new URL('/', marketingUrl ?? 'https://curvit.io').toString(); diff --git a/apps/app-frontend/tests/e2e/admin-visibility.spec.ts b/apps/app-frontend/tests/e2e/admin-visibility.spec.ts index 4f9f2080..4b45ff91 100644 --- a/apps/app-frontend/tests/e2e/admin-visibility.spec.ts +++ b/apps/app-frontend/tests/e2e/admin-visibility.spec.ts @@ -8,7 +8,7 @@ async function forceAuthMode(page: Page, mode: 'admin' | 'non-admin-user') { { name: 'curvit_e2e_auth', value: mode, - url: 'http://127.0.0.1:41017', + url: 'http://127.0.0.1:41017', // NOSONAR: S5332 - E2E test cookie target; loopback only. httpOnly: true, secure: false, sameSite: 'Lax', diff --git a/apps/app-frontend/tests/unit/app/api/admin/users/data-export.test.ts b/apps/app-frontend/tests/unit/app/api/admin/users/data-export.test.ts index a2ac151a..a1ded2e1 100644 --- a/apps/app-frontend/tests/unit/app/api/admin/users/data-export.test.ts +++ b/apps/app-frontend/tests/unit/app/api/admin/users/data-export.test.ts @@ -16,7 +16,7 @@ const ADMIN_SESSION = { }; function makeRequest(): Request { - return new Request('http://localhost/api/admin/users/user-1/data-export'); + return new Request('http://localhost/api/admin/users/user-1/data-export'); // NOSONAR: S5332 - test-only localhost URL for constructing Request objects. } function makeParams(userId = 'user-1') { diff --git a/apps/app-frontend/tests/unit/app/api/billing/checkout.test.ts b/apps/app-frontend/tests/unit/app/api/billing/checkout.test.ts index 64f18f20..6e3d0c36 100644 --- a/apps/app-frontend/tests/unit/app/api/billing/checkout.test.ts +++ b/apps/app-frontend/tests/unit/app/api/billing/checkout.test.ts @@ -12,7 +12,7 @@ const { redirect } = await import('next/navigation'); function makeCheckoutRequest(tier: string): Request { const body = new FormData(); body.append('tier', tier); - return new Request('http://localhost/api/billing/checkout', { + return new Request('http://localhost/api/billing/checkout', { // NOSONAR: S5332 - test-only localhost URL for constructing Request objects. method: 'POST', body, }); diff --git a/apps/app-frontend/tests/unit/app/api/e2e/auth.test.ts b/apps/app-frontend/tests/unit/app/api/e2e/auth.test.ts index 6968d895..842e1bcb 100644 --- a/apps/app-frontend/tests/unit/app/api/e2e/auth.test.ts +++ b/apps/app-frontend/tests/unit/app/api/e2e/auth.test.ts @@ -16,7 +16,7 @@ function makePostRequest(fields: Record = {}) { for (const [key, value] of Object.entries(fields)) { body.append(key, value); } - return new Request('http://localhost/api/e2e/auth', { method: 'POST', body }); + return new Request('http://localhost/api/e2e/auth', { method: 'POST', body }); // NOSONAR: S5332 - test-only localhost URL for constructing Request objects. } describe('GET /api/e2e/auth — token disclosure prevention', () => { diff --git a/apps/app-frontend/tests/unit/lib/api/account.test.ts b/apps/app-frontend/tests/unit/lib/api/account.test.ts index 079df1b7..8392901b 100644 --- a/apps/app-frontend/tests/unit/lib/api/account.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/account.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { getAccount, deleteAccount } from '@/lib/api/account'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/admin.test.ts b/apps/app-frontend/tests/unit/lib/api/admin.test.ts index 5fbd8a2d..fba942b6 100644 --- a/apps/app-frontend/tests/unit/lib/api/admin.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/admin.test.ts @@ -7,7 +7,7 @@ import { adminDeleteUserAccount, } from '@/lib/api/admin'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/analyses.test.ts b/apps/app-frontend/tests/unit/lib/api/analyses.test.ts index 371dd058..545ce9d9 100644 --- a/apps/app-frontend/tests/unit/lib/api/analyses.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/analyses.test.ts @@ -7,7 +7,7 @@ import { getAnalysisResult, } from '@/lib/api/analyses'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/billing.test.ts b/apps/app-frontend/tests/unit/lib/api/billing.test.ts index 51e323e8..0d53559f 100644 --- a/apps/app-frontend/tests/unit/lib/api/billing.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/billing.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { getBillingStatus, createCheckoutSession, createPortalSession } from '@/lib/api/billing'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/blog.test.ts b/apps/app-frontend/tests/unit/lib/api/blog.test.ts index efca2673..a2ab3539 100644 --- a/apps/app-frontend/tests/unit/lib/api/blog.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/blog.test.ts @@ -10,7 +10,7 @@ import { type SaveBlogPostPayload, } from '@/lib/api/blog'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/client.test.ts b/apps/app-frontend/tests/unit/lib/api/client.test.ts index cdf34738..8f05bd7f 100644 --- a/apps/app-frontend/tests/unit/lib/api/client.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/client.test.ts @@ -4,7 +4,7 @@ import { setupServer } from 'msw/node'; import { apiRequest } from '@/lib/api/client'; // Mock base URL for tests -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/documents.test.ts b/apps/app-frontend/tests/unit/lib/api/documents.test.ts index 66d02120..4501d58e 100644 --- a/apps/app-frontend/tests/unit/lib/api/documents.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/documents.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { listDocuments, uploadDocument } from '@/lib/api/documents'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/job-specs.test.ts b/apps/app-frontend/tests/unit/lib/api/job-specs.test.ts index 2be74788..73885502 100644 --- a/apps/app-frontend/tests/unit/lib/api/job-specs.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/job-specs.test.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { listJobSpecs, uploadJobSpec } from '@/lib/api/job-specs'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/messages.test.ts b/apps/app-frontend/tests/unit/lib/api/messages.test.ts index 1539b4e7..2b698561 100644 --- a/apps/app-frontend/tests/unit/lib/api/messages.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/messages.test.ts @@ -7,7 +7,7 @@ import { updateAdminMessage, } from '@/lib/api/messages'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/api/screening.test.ts b/apps/app-frontend/tests/unit/lib/api/screening.test.ts index a82c9879..fe6c0307 100644 --- a/apps/app-frontend/tests/unit/lib/api/screening.test.ts +++ b/apps/app-frontend/tests/unit/lib/api/screening.test.ts @@ -7,7 +7,7 @@ import { createScreeningSession, } from '@/lib/api/screening'; -process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; +process.env.NEXT_PUBLIC_API_URL = 'http://localhost:5000'; // NOSONAR: S5332 - test-only MSW mock base URL; not a real network connection. process.env.INTERNAL_API_URL = ''; const server = setupServer(); diff --git a/apps/app-frontend/tests/unit/lib/logoutCookies.test.ts b/apps/app-frontend/tests/unit/lib/logoutCookies.test.ts index 261dc33f..f02b80c3 100644 --- a/apps/app-frontend/tests/unit/lib/logoutCookies.test.ts +++ b/apps/app-frontend/tests/unit/lib/logoutCookies.test.ts @@ -26,7 +26,7 @@ describe('buildExpiredAuthCookies', () => { it('keeps Playwright local login redirect in E2E mode', () => { process.env.PLAYWRIGHT_E2E = '1'; - expect(buildMarketingLogoutUrl('http://127.0.0.1:41017')).toBe('http://127.0.0.1:41017/login?loggedOut=1'); + expect(buildMarketingLogoutUrl('http://127.0.0.1:41017')).toBe('http://127.0.0.1:41017/login?loggedOut=1'); // NOSONAR: S5332 - E2E test assertion; loopback URL only. delete process.env.PLAYWRIGHT_E2E; }); }); diff --git a/apps/marketing-site/playwright.config.ts b/apps/marketing-site/playwright.config.ts index 38b1e7ef..832781f6 100644 --- a/apps/marketing-site/playwright.config.ts +++ b/apps/marketing-site/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test'; +// All http://localhost:4322 URLs in this file are loopback addresses used +// exclusively for Playwright E2E tests. Not used in production traffic. +// See docs/security/internal-networking.md. + const includeMobileSafari = !!process.env.CI || process.env.PLAYWRIGHT_INCLUDE_WEBKIT === '1'; export default defineConfig({ @@ -9,7 +13,7 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : 'html', use: { - baseURL: 'http://localhost:4322', + baseURL: 'http://localhost:4322', // NOSONAR: S5332 - E2E test loopback URL; not used in production. trace: 'on-first-retry', }, projects: [ @@ -28,7 +32,7 @@ export default defineConfig({ ], webServer: { command: 'npm run build && npx astro preview --port 4322', - url: 'http://localhost:4322', + url: 'http://localhost:4322', // NOSONAR: S5332 - E2E test loopback URL. reuseExistingServer: false, env: { ...process.env, diff --git a/apps/marketing-site/src/config.ts b/apps/marketing-site/src/config.ts index bbdf4c5b..1639b766 100644 --- a/apps/marketing-site/src/config.ts +++ b/apps/marketing-site/src/config.ts @@ -11,4 +11,4 @@ export const appUrl: string = export const internalAppUrl: string = process.env.INTERNAL_APP_URL ?? import.meta.env.INTERNAL_APP_URL - ?? 'http://app-frontend:3000'; + ?? 'http://app-frontend:3000'; // NOSONAR: S5332 - Internal Docker URL; SSR server-side only. See docs/security/internal-networking.md. diff --git a/apps/marketing-site/src/lib/search/index.ts b/apps/marketing-site/src/lib/search/index.ts index b0a67c03..fd765bb8 100644 --- a/apps/marketing-site/src/lib/search/index.ts +++ b/apps/marketing-site/src/lib/search/index.ts @@ -98,7 +98,7 @@ async function buildBlogIndex(): Promise { return cachedBlogIndex.results; } - const cmsBase = process.env.CMS_SERVICE_URL || 'http://cms-service:8000'; + const cmsBase = process.env.CMS_SERVICE_URL || 'http://cms-service:8000'; // NOSONAR: S5332 - Internal Docker URL; SSR server-side only. const internalApiKey = process.env.INTERNAL_API_KEY || ''; const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined; diff --git a/apps/marketing-site/tests/unit/publicMessages.test.ts b/apps/marketing-site/tests/unit/publicMessages.test.ts index 90d65f4b..b969a31e 100644 --- a/apps/marketing-site/tests/unit/publicMessages.test.ts +++ b/apps/marketing-site/tests/unit/publicMessages.test.ts @@ -5,6 +5,10 @@ import { replyToPublicMessage, } from '../../src/lib/publicMessages'; +// The http://messaging-service:8000 URLs below are internal Docker service addresses +// passed as parameters to the library under test. fetch() is mocked (vi.spyOn); +// no real network connections are made. See docs/security/internal-networking.md. + afterEach(() => { vi.restoreAllMocks(); delete process.env.INTERNAL_API_KEY; @@ -21,7 +25,7 @@ describe('createPublicMessage', () => { process.env.INTERNAL_API_KEY = 'test-internal-key'; - const result = await createPublicMessage('http://messaging-service:8000', { + const result = await createPublicMessage('http://messaging-service:8000', { // NOSONAR: S5332 firstName: 'Ava', lastName: 'Stone', email: 'ava@example.com', @@ -55,7 +59,7 @@ describe('createPublicMessage', () => { error: 'A valid email address is required.', }), { status: 400 })); - const result = await createPublicMessage('http://messaging-service:8000', { + const result = await createPublicMessage('http://messaging-service:8000', { // NOSONAR: S5332 firstName: 'Ava', lastName: 'Stone', email: 'invalid', @@ -82,7 +86,7 @@ describe('lookupPublicMessage', () => { replies: [], }), { status: 200 })); - const result = await lookupPublicMessage('http://messaging-service:8000', { + const result = await lookupPublicMessage('http://messaging-service:8000', { // NOSONAR: S5332 referenceNumber: 'MSG-20260409-ABC123', email: 'ava@example.com', }); @@ -98,7 +102,7 @@ describe('replyToPublicMessage', () => { it('falls back to the default error message when the API body is not JSON', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('server unavailable', { status: 502 })); - const result = await replyToPublicMessage('http://messaging-service:8000', { + const result = await replyToPublicMessage('http://messaging-service:8000', { // NOSONAR: S5332 referenceNumber: 'MSG-20260409-ABC123', email: 'ava@example.com', body: 'Can you confirm the invoice number you need?', diff --git a/data/Testing/test_auth_harness.py b/data/Testing/test_auth_harness.py index 55922699..1055548e 100644 --- a/data/Testing/test_auth_harness.py +++ b/data/Testing/test_auth_harness.py @@ -77,12 +77,12 @@ def load_env(path: pathlib.Path) -> dict[str, str]: ENV = load_env(REPO_ROOT / ".env") HARNESS_PORT = 8100 -AUTH_SERVER = "http://localhost:9000" # Authentik public URL +AUTH_SERVER = "http://localhost:9000" # Authentik public URL # NOSONAR: S5332 - Manual test harness connecting to local Authentik dev server. APP_SLUG = "curvit" # Authentik application slug CLIENT_ID = ENV.get("AUTH_AUTHENTIK_ID", "") CLIENT_SECRET = ENV.get("AUTH_AUTHENTIK_SECRET", "") -REDIRECT_URI = f"http://localhost:{HARNESS_PORT}/callback" -POST_LOGOUT_URI = f"http://localhost:{HARNESS_PORT}/logged-out" +REDIRECT_URI = f"http://localhost:{HARNESS_PORT}/callback" # NOSONAR: S5332 - Local dev harness loopback callback. +POST_LOGOUT_URI = f"http://localhost:{HARNESS_PORT}/logged-out" # NOSONAR: S5332 - Local dev harness loopback callback. ISSUER = f"{AUTH_SERVER}/application/o/{APP_SLUG}/" DISCOVERY_URL = f"{ISSUER}.well-known/openid-configuration" diff --git a/data/Testing/test_upload_harness.py b/data/Testing/test_upload_harness.py index 84a9f2dc..d140dca0 100644 --- a/data/Testing/test_upload_harness.py +++ b/data/Testing/test_upload_harness.py @@ -31,7 +31,7 @@ sys.exit("Missing dependency: run pip install requests") -CORE_API = "http://localhost:5000" +CORE_API = "http://localhost:5000" # NOSONAR: S5332 - Manual test harness; connects to a local dev server only. DOCUMENTS_URL = f"{CORE_API}/api/v1/documents" # Stable test-user identity - the dev middleware creates this account on first upload. diff --git a/docs/security/internal-networking.md b/docs/security/internal-networking.md new file mode 100644 index 00000000..b4c78b0d --- /dev/null +++ b/docs/security/internal-networking.md @@ -0,0 +1,193 @@ +# Internal Service Networking and TLS Boundary + +## Summary + +Curvit uses HTTPS everywhere for public traffic. Internal service-to-service +communication uses plain HTTP over a private Docker network where TLS +termination at the edge proxy is sufficient. + +This document explains the security rationale, the boundary conditions, and +the circumstances under which internal HTTP is and is not acceptable. + +--- + +## TLS architecture + +``` +Browser / API client + │ HTTPS (TLS 1.2+, Let's Encrypt) + ▼ + ┌─────────────┐ + │ Traefik │ Edge reverse proxy — terminates TLS, enforces HSTS, + │ (public) │ applies security headers, rate limits public endpoints. + └─────────────┘ + │ HTTP (plain) — private Docker network only + ▼ + ┌────────────────────────────────────────────────────────────────────┐ + │ Private Docker network │ + │ │ + │ app-frontend ─→ core-api ─→ ai-orchestrator │ + │ │ content-sanitiser │ + │ │ output-validator │ + │ │ document-ingestion-service │ + │ │ document-renderer │ + │ ▼ │ + │ analysis-worker batch-ranking-worker │ + │ │ + │ marketing-site ─→ cms-service messaging-service │ + │ billing-service admin-service │ + └────────────────────────────────────────────────────────────────────┘ +``` + +- Public traffic enters only through Traefik on ports 80 and 443. +- Traefik forces HTTPS for all public-facing routes and issues HTTP→HTTPS + redirects on port 80. +- Internal services communicate using Docker service DNS names + (e.g. `http://core-api:5000`). These addresses are only resolvable inside + the Docker network and are never reachable from the public internet. +- Database (PostgreSQL) and message broker (Redis) ports are not published + externally; they communicate via the private Docker network only. + +--- + +## Why internal HTTP is acceptable + +Internal HTTP between Docker containers on the same host is safe when ALL of +the following conditions are true: + +| Condition | Status | +|-----------|--------| +| URL uses a Docker service DNS name (e.g. `http://core-api:5000`) | ✅ | +| The call is server-side only — not browser-facing | ✅ | +| The internal service port is not published to the public internet | ✅ | +| Traffic stays on the private Docker network on the same host | ✅ | +| External/public traffic is protected by HTTPS at Traefik | ✅ | +| No secrets are transmitted over an untrusted network | ✅ | + +### Docker Compose port exposure audit + +Internal service ports are **not** published publicly. Only Traefik ports +80 and 443 are bound to public interfaces. All other services use `expose` +(internal only) rather than `ports` (host binding), or are only reachable via +the Traefik reverse proxy. + +--- + +## Classification of HTTP URL hotspots + +### Internal Docker-network service URLs — SAFE + +The following URLs use Docker service DNS names and are unreachable from the +public internet. They are used server-side only. TLS is terminated at Traefik. + +**TypeScript source files:** + +| File | URL pattern | Justification | +|------|-------------|---------------| +| `apps/app-frontend/src/lib/api/health.ts` | `http://:/health` | Server-side admin health dashboard — Next.js server component, not browser-facing | +| `apps/app-frontend/src/lib/api/internal-auth.ts` | `http://core-api:5000` | Internal auth callbacks from Next.js server to core-api | +| `apps/app-frontend/src/lib/api/account.ts` | `http://core-api:5000` | Server-side data-export fetch | +| `apps/app-frontend/src/lib/api/messages.ts` | `http://messaging-service:8000` | Server-side messaging API proxy | +| `apps/app-frontend/src/lib/api/blog.ts` | `http://cms-service:8000` | Server-side CMS API proxy | +| `apps/app-frontend/src/lib/api/admin.ts` | `http://admin-service:8000`, `http://billing-service:8000`, `http://core-api:5000` | Server-side admin proxy requests | +| `apps/app-frontend/src/lib/api/billing.ts` | `http://billing-service:8000` | Server-side billing API proxy | +| `apps/app-frontend/src/middleware.ts` | `http://core-api:5000` | Server-side middleware auth validation | +| `apps/marketing-site/src/config.ts` | `http://app-frontend:3000` | SSR internal link — only used during server-side rendering, not browser-facing | +| `apps/marketing-site/src/lib/search/index.ts` | `http://cms-service:8000` | SSR blog index fetch | + +**Python source files:** + +| File | URL pattern | Justification | +|------|-------------|---------------| +| `workers/analysis-worker/app/config.py` | `http://core-api:5000`, `http://document-ingestion-service:8001`, `http://content-sanitiser:8000`, `http://ai-orchestrator:8000`, `http://output-validator:8000`, `http://document-renderer:8000` | Worker-to-service calls on private Docker network | +| `services/admin-service/app/config.py` | `http://core-api:8080` | Admin service callback to core-api on private Docker network | +| `services/document-ingestion-service/app/config.py` | `http://clamav-rest:8080` | ClamAV REST scanner on private Docker network | +| `shared/config.py` | `http://localhost:3000` | Development-only default for `auth_issuer`; overridden by env var in staging/production | +| `services/billing-service/app/config.py` | `http://localhost:3000` | Development-only default for `app_base_url`; overridden by env var in staging/production | + +### Test-only localhost/internal URLs — SAFE + +These URLs appear exclusively in automated test suites. They are never used in +production traffic. + +- `workers/analysis-worker/tests/test_internal_api_key.py` — Docker service + URLs used as mock settings values; no real network connections are made. +- `services/document-ingestion-service/tests/` — `http://clamav:8080` used + as mock scanner URL in unit tests. +- `apps/app-frontend/tests/unit/lib/api/*.test.ts` — `http://localhost:5000` + set as the MSW mock base URL; requests are intercepted by MSW and never + reach a real server. +- `apps/marketing-site/tests/unit/publicMessages.test.ts` — Docker service + URL passed to stub functions; no real network connections are made. +- `services/*/tests/conftest.py` — `http://test` used as the httpx + `AsyncClient` base URL; this is a standard httpx test utility convention + and does not make real network connections. +- `apps/app-frontend/playwright.config.ts`, `apps/marketing-site/playwright.config.ts` — + `http://127.0.0.1:41017` and `http://localhost:4322` are local E2E test + server URLs; connections stay on loopback. + +### E2E test fallback URLs in source files — SAFE + +The following http:// fallback URLs in application source files are guarded by +`process.env.PLAYWRIGHT_E2E === '1'` checks and are only ever reached during +E2E test runs on a loopback interface. They are never executed in production. + +- `apps/app-frontend/src/lib/api/billing.ts` — `http://127.0.0.1:41017` + checkout/portal stubs, inside `isPlaywrightE2EEnabled()` guard. +- `apps/app-frontend/src/lib/auth/logoutCookies.ts` — `http://127.0.0.1:41017` + fallback, inside `process.env.PLAYWRIGHT_E2E === '1'` guard. +- `apps/app-frontend/src/app/api/e2e/auth/route.ts` — `http://127.0.0.1:41017` + redirect fallback for E2E auth helper route. + +### XML/JSON schema namespace identifiers — SAFE + +The following strings look like HTTP URLs but are static namespace identifiers +used by XML parsers and JSON Schema validators. They are not network connection +strings. + +- `services/document-ingestion-service/app/services/extractors/docx_extractor.py` — + `http://schemas.openxmlformats.org/wordprocessingml/2006/main` (OOXML namespace). +- `services/document-ingestion-service/tests/conftest.py` — OOXML namespace + URIs in synthetic test DOCX XML content. +- `services/ai-orchestrator/tests/test_minimal_schema.py` — + `http://json-schema.org/draft-07/schema#` (JSON Schema spec identifier). + +--- + +## What is NOT acceptable + +The following categories of HTTP URLs are **not** classified as safe and must +use HTTPS: + +- Any URL using a public hostname such as `http://curvit.co.uk`, + `http://staging.curvit.co.uk`, or `http://app.curvit.co.uk`. +- Any URL exposed to the browser via `NEXT_PUBLIC_*` environment variables. +- Any URL that transmits user credentials, session tokens, or payment + information over a non-loopback, non-Docker-private network. +- Any URL used by a service deployed on a different host without encrypted + private networking or mTLS. + +--- + +## Future considerations + +If Curvit moves to a multi-host production deployment (e.g. Kubernetes, +distributed containers across multiple VMs), the following steps are required: + +1. Evaluate encrypted overlay networking (e.g. WireGuard or IPSec-backed + overlay in Kubernetes) or service mesh mTLS (e.g. Istio, Linkerd). +2. Alternatively, configure TLS on each internal service endpoint with + certificate-based mutual authentication. +3. Update this document to reflect the new trust model. + +Until a multi-host topology is adopted, internal Docker HTTP on a single-host +private network remains the accepted pattern. + +--- + +## References + +- [OWASP Transport Layer Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html) +- [Docker networking overview](https://docs.docker.com/network/) +- [Traefik TLS configuration](https://doc.traefik.io/traefik/https/tls/) +- SonarCloud rule: [python:S5332 / typescript:S5332 — Use of insecure connection](https://rules.sonarsource.com/python/RSPEC-5332/) diff --git a/services/admin-service/app/config.py b/services/admin-service/app/config.py index 74062871..29715b7b 100644 --- a/services/admin-service/app/config.py +++ b/services/admin-service/app/config.py @@ -5,8 +5,9 @@ class Settings(BaseServiceSettings): """Admin-service configuration (extends shared base configuration).""" - # Core API callback for dead-letter queue operations - core_api_base_url: str = "http://core-api:8080" + # Internal Docker network URL. Not exposed publicly; TLS terminated at Traefik. + # See docs/security/internal-networking.md. + core_api_base_url: str = "http://core-api:8080" # NOSONAR: S5332 redis_url: str = "redis://localhost:6379" diff --git a/services/admin-service/tests/conftest.py b/services/admin-service/tests/conftest.py index 48f82eb7..17eea7f0 100644 --- a/services/admin-service/tests/conftest.py +++ b/services/admin-service/tests/conftest.py @@ -52,7 +52,7 @@ async def override_get_session(): app.dependency_overrides[get_session] = override_get_session transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: # NOSONAR: S5332 - standard httpx test transport placeholder; no real network connection. yield test_client app.dependency_overrides.clear() diff --git a/services/ai-orchestrator/tests/test_minimal_schema.py b/services/ai-orchestrator/tests/test_minimal_schema.py index 8f0c9377..f61b7b32 100644 --- a/services/ai-orchestrator/tests/test_minimal_schema.py +++ b/services/ai-orchestrator/tests/test_minimal_schema.py @@ -19,7 +19,7 @@ def test_empty_object(self): def test_metadata_fields_stripped(self): schema = { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "http://json-schema.org/draft-07/schema#", # NOSONAR: S5332 - JSON Schema spec identifier, not a network request. "title": "MySchema", "additionalProperties": False, "minLength": 1, diff --git a/services/billing-service/app/config.py b/services/billing-service/app/config.py index 2bf72ab2..f5877ef6 100644 --- a/services/billing-service/app/config.py +++ b/services/billing-service/app/config.py @@ -8,7 +8,7 @@ class Settings(BaseServiceSettings): # Stripe configuration stripe_secret_key: str = "" stripe_webhook_secret: str = "" - app_base_url: str = "http://localhost:3000" + app_base_url: str = "http://localhost:3000" # NOSONAR: S5332 - Development-only default; overridden by env var in staging/production. @lru_cache diff --git a/services/billing-service/tests/conftest.py b/services/billing-service/tests/conftest.py index 9e82e2dc..34378594 100644 --- a/services/billing-service/tests/conftest.py +++ b/services/billing-service/tests/conftest.py @@ -52,7 +52,7 @@ async def override_get_session(): app.dependency_overrides[get_session] = override_get_session transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: # NOSONAR: S5332 - standard httpx test transport placeholder; no real network connection. yield test_client app.dependency_overrides.clear() diff --git a/services/cms-service/tests/conftest.py b/services/cms-service/tests/conftest.py index 17ea8f69..a91d5c5b 100644 --- a/services/cms-service/tests/conftest.py +++ b/services/cms-service/tests/conftest.py @@ -52,7 +52,7 @@ async def override_get_session(): app.dependency_overrides[get_session] = override_get_session transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: # NOSONAR: S5332 - standard httpx test transport placeholder; no real network connection. yield test_client app.dependency_overrides.clear() diff --git a/services/document-ingestion-service/app/config.py b/services/document-ingestion-service/app/config.py index 00503a96..38f332a2 100644 --- a/services/document-ingestion-service/app/config.py +++ b/services/document-ingestion-service/app/config.py @@ -13,7 +13,9 @@ class Settings(BaseSettings): # Malware scanner (ClamAV REST). Mandatory in all environments — uploads are # rejected when the scanner is unavailable (fail-closed). - scanner_url: str = "http://clamav-rest:8080" + # Internal Docker network URL. Not exposed publicly; TLS terminated at Traefik. + # See docs/security/internal-networking.md. + scanner_url: str = "http://clamav-rest:8080" # NOSONAR: S5332 # Must be false in all environments: reject files when scanner is unavailable. scanner_fail_open: bool = False scanner_timeout_s: int = 30 diff --git a/services/document-ingestion-service/app/services/extractors/docx_extractor.py b/services/document-ingestion-service/app/services/extractors/docx_extractor.py index ad23efb9..8b23bd47 100644 --- a/services/document-ingestion-service/app/services/extractors/docx_extractor.py +++ b/services/document-ingestion-service/app/services/extractors/docx_extractor.py @@ -92,7 +92,7 @@ def _append_section_parts(parts: list[str], doc) -> None: def _append_textbox_parts(parts: list[str], doc) -> None: # Office Open XML namespace URI — not a network connection. Fixed identifier used by the XML parser. # Must match the standard OOXML schema to correctly parse textbox elements from Word documents. - namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" # NOSONAR: S5332 - XML namespace identifier, not a network request. for txbx in doc.element.iter(f"{{{namespace}}}txbxContent"): for t_node in txbx.iter(f"{{{namespace}}}t"): if t_node.text: diff --git a/services/document-ingestion-service/tests/conftest.py b/services/document-ingestion-service/tests/conftest.py index 000364c8..c6eee9ee 100644 --- a/services/document-ingestion-service/tests/conftest.py +++ b/services/document-ingestion-service/tests/conftest.py @@ -58,7 +58,7 @@ def _build_minimal_docx(include_vba: bool = False) -> bytes: buf = io.BytesIO() content_types = ( '' - '' + '' # NOSONAR: S5332 - OOXML namespace URI; static XML identifier, not a network request. '' '' ' bytes: ) document_xml = ( '' - '' + '' # NOSONAR: S5332 - OOXML namespace URI; static XML identifier, not a network request. "Hello CV" "" ) rels = ( '' - '' - '' # NOSONAR: S5332 - OOXML namespace URI; static XML identifier, not a network request. + '' "" ) diff --git a/services/document-ingestion-service/tests/test_ingestion_zip_scan.py b/services/document-ingestion-service/tests/test_ingestion_zip_scan.py index ab312263..45b7bdeb 100644 --- a/services/document-ingestion-service/tests/test_ingestion_zip_scan.py +++ b/services/document-ingestion-service/tests/test_ingestion_zip_scan.py @@ -30,7 +30,7 @@ def _build_zip(*members: tuple[str, bytes]) -> bytes: return buf.getvalue() -def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): +def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): # NOSONAR: S5332 - test-only mock URL; no real network connection made. s = MagicMock() s.scanner_url = scanner_url s.scanner_fail_open = scanner_fail_open diff --git a/services/document-ingestion-service/tests/test_metrics.py b/services/document-ingestion-service/tests/test_metrics.py index 48c054ab..dba39d1d 100644 --- a/services/document-ingestion-service/tests/test_metrics.py +++ b/services/document-ingestion-service/tests/test_metrics.py @@ -6,7 +6,7 @@ from tests.conftest import MINIMAL_PDF_WITH_TEXT, PNG_BYTES -def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): +def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): # NOSONAR: S5332 - test-only mock URL; no real network connection made. settings = MagicMock() settings.scanner_url = scanner_url settings.scanner_fail_open = scanner_fail_open diff --git a/services/document-ingestion-service/tests/test_post_extraction_scan.py b/services/document-ingestion-service/tests/test_post_extraction_scan.py index b5e2d503..6e9b5775 100644 --- a/services/document-ingestion-service/tests/test_post_extraction_scan.py +++ b/services/document-ingestion-service/tests/test_post_extraction_scan.py @@ -8,7 +8,7 @@ _EICAR = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" -def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): +def _make_settings(*, scanner_url: str = "http://clamav:8080", scanner_fail_open: bool = False): # NOSONAR: S5332 - test-only mock URL; no real network connection made. settings = MagicMock() settings.scanner_url = scanner_url settings.scanner_fail_open = scanner_fail_open diff --git a/services/document-ingestion-service/tests/test_scanner_hook.py b/services/document-ingestion-service/tests/test_scanner_hook.py index 7ac27652..72cf93c5 100644 --- a/services/document-ingestion-service/tests/test_scanner_hook.py +++ b/services/document-ingestion-service/tests/test_scanner_hook.py @@ -22,7 +22,7 @@ def _make_mock_client(status_code: int, json_data: dict): return MagicMock(return_value=mock_client) -def _make_mock_settings(scanner_url: str = "", scanner_fail_open: bool = False, scanner_timeout_s: int = 5): +def _make_mock_settings(scanner_url: str = "", scanner_fail_open: bool = False, scanner_timeout_s: int = 5): # NOSONAR: S5332 - test-only mock URL parameter; no real network connection made. s = MagicMock() s.scanner_url = scanner_url s.scanner_fail_open = scanner_fail_open @@ -52,7 +52,7 @@ def test_scan_unconfigured_emits_event(capsys) -> None: # ── Clean file ──────────────────────────────────────────────────────────────── def test_scan_clean_returns_clean() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(200, {"malware": False, "description": ""}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): @@ -61,7 +61,7 @@ def test_scan_clean_returns_clean() -> None: def test_scan_uses_api_v1_files_endpoint_for_current_clamav_image() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_response = MagicMock() mock_response.status_code = 200 @@ -83,12 +83,12 @@ def test_scan_uses_api_v1_files_endpoint_for_current_clamav_image() -> None: assert result == "clean" mock_client_instance.post.assert_awaited_once() args, kwargs = mock_client_instance.post.await_args - assert args[0] == "http://clamav:8080/api/v1/scan" + assert args[0] == "http://clamav:8080/api/v1/scan" # NOSONAR: S5332 - test assertion verifying mock URL construction. assert set(kwargs["files"].keys()) == {"FILES"} def test_scan_clean_emits_event(capsys) -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(200, {"malware": False, "description": ""}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): @@ -102,7 +102,7 @@ def test_scan_clean_emits_event(capsys) -> None: # ── Infected file ───────────────────────────────────────────────────────────── def test_scan_infected_returns_infected() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(406, {"malware": True, "description": "Eicar-Test-Signature"}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): @@ -112,7 +112,7 @@ def test_scan_infected_returns_infected() -> None: def test_scan_infected_returns_infected_current_api_format() -> None: """Verify _response_has_malware parses the benzino77/clamav-rest-api v1 payload.""" - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client( 200, { @@ -131,7 +131,7 @@ def test_scan_infected_returns_infected_current_api_format() -> None: def test_scan_infected_emits_event(capsys) -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(406, {"malware": True, "description": "Eicar-Test-Signature"}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): @@ -145,7 +145,7 @@ def test_scan_infected_emits_event(capsys) -> None: # ── Scanner unavailable ─────────────────────────────────────────────────────── def test_scan_connection_error_returns_unavailable() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client_instance = AsyncMock() mock_client_instance.post = AsyncMock( @@ -162,7 +162,7 @@ def test_scan_connection_error_returns_unavailable() -> None: def test_scan_timeout_returns_unavailable() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client_instance = AsyncMock() mock_client_instance.post = AsyncMock( @@ -179,7 +179,7 @@ def test_scan_timeout_returns_unavailable() -> None: def test_scan_unexpected_status_returns_unavailable() -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(503, {}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): @@ -188,7 +188,7 @@ def test_scan_unexpected_status_returns_unavailable() -> None: def test_scan_unavailable_emits_error_event(capsys) -> None: - settings = _make_mock_settings(scanner_url="http://clamav:8080") + settings = _make_mock_settings(scanner_url="http://clamav:8080") # NOSONAR: S5332 - test-only mock URL mock_client = _make_mock_client(503, {}) with patch("app.services.scanner_hook.get_settings", return_value=settings), \ patch("httpx.AsyncClient", mock_client): diff --git a/services/messaging-service/tests/conftest.py b/services/messaging-service/tests/conftest.py index fc5eb457..abc705b7 100644 --- a/services/messaging-service/tests/conftest.py +++ b/services/messaging-service/tests/conftest.py @@ -52,7 +52,7 @@ async def override_get_session(): app.dependency_overrides[get_session] = override_get_session transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as test_client: # NOSONAR: S5332 - standard httpx test transport placeholder; no real network connection. yield test_client app.dependency_overrides.clear() diff --git a/shared/config.py b/shared/config.py index de896f43..93e15d59 100644 --- a/shared/config.py +++ b/shared/config.py @@ -38,7 +38,7 @@ class BaseServiceSettings(BaseSettings): # Core/Common configuration database_url: str = "postgresql+asyncpg://localhost/curvit" auth_secret: str = "" - auth_issuer: str = "http://localhost:3000" + auth_issuer: str = "http://localhost:3000" # NOSONAR: S5332 - Development-only default; overridden by env var in staging/production. auth_audience: str = "curvit-api" internal_api_key: str = "" app_env: str = "development" diff --git a/workers/analysis-worker/app/config.py b/workers/analysis-worker/app/config.py index 7a3656db..3b243014 100644 --- a/workers/analysis-worker/app/config.py +++ b/workers/analysis-worker/app/config.py @@ -12,12 +12,15 @@ class Settings(BaseSettings): analysis_dead_letter_queue: str = "analysis-worker-dead-letter" analysis_dead_letter_max_entries: int = 500 - core_api_base_url: str = "http://core-api:5000" - ingestion_service_url: str = "http://document-ingestion-service:8001" - sanitiser_service_url: str = "http://content-sanitiser:8000" - orchestrator_url: str = "http://ai-orchestrator:8000" - validator_url: str = "http://output-validator:8000" - renderer_url: str = "http://document-renderer:8000" + # Internal Docker network service URLs. Not browser-facing and not exposed publicly. + # TLS is terminated at Traefik; service-to-service traffic stays on the private Docker network. + # See docs/security/internal-networking.md. + core_api_base_url: str = "http://core-api:5000" # NOSONAR: S5332 + ingestion_service_url: str = "http://document-ingestion-service:8001" # NOSONAR: S5332 + sanitiser_service_url: str = "http://content-sanitiser:8000" # NOSONAR: S5332 + orchestrator_url: str = "http://ai-orchestrator:8000" # NOSONAR: S5332 + validator_url: str = "http://output-validator:8000" # NOSONAR: S5332 + renderer_url: str = "http://document-renderer:8000" # NOSONAR: S5332 max_retries: int = 3 retry_backoff_base_s: int = 5 diff --git a/workers/analysis-worker/tests/test_internal_api_key.py b/workers/analysis-worker/tests/test_internal_api_key.py index 72712c7a..074d7c53 100644 --- a/workers/analysis-worker/tests/test_internal_api_key.py +++ b/workers/analysis-worker/tests/test_internal_api_key.py @@ -3,7 +3,12 @@ This complements the existing functional tests in test_service_clients.py by explicitly asserting on the authentication header behaviour. + +The http:// Docker service URLs used in mock_settings below are +test-only values — no real network connections are made. +See docs/security/internal-networking.md for the classification rationale. """ +# NOSONAR: S5332 - This test file uses Docker service URLs as mock settings values only. from __future__ import annotations from unittest.mock import MagicMock, patch, ANY @@ -62,7 +67,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.job_fetcher.httpx.Client", return_value=mock_client), patch("app.services.job_fetcher.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY fetch_job("abc-123") headers = _get_headers_from_call(mock_client, "get") @@ -74,7 +79,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.job_fetcher.httpx.Client", return_value=mock_client), patch("app.services.job_fetcher.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" fetch_job("abc-123") headers = _get_headers_from_call(mock_client, "get") @@ -93,7 +98,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.sanitiser_service_url = "http://content-sanitiser:8000" + mock_settings.sanitiser_service_url = "http://content-sanitiser:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY sanitise("job-1", "text") headers = _get_headers_from_call(mock_client, "post") @@ -105,7 +110,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.sanitiser_service_url = "http://content-sanitiser:8000" + mock_settings.sanitiser_service_url = "http://content-sanitiser:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" sanitise("job-1", "text") headers = _get_headers_from_call(mock_client, "post") @@ -124,7 +129,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.orchestrator_client.httpx.Client", return_value=mock_client), patch("app.services.orchestrator_client.settings") as mock_settings, ): - mock_settings.orchestrator_url = "http://ai-orchestrator:8000" + mock_settings.orchestrator_url = "http://ai-orchestrator:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY orchestrate("job-1", "user-1", "cv text", "cv_review") headers = _get_headers_from_call(mock_client, "post") @@ -136,7 +141,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.orchestrator_client.httpx.Client", return_value=mock_client), patch("app.services.orchestrator_client.settings") as mock_settings, ): - mock_settings.orchestrator_url = "http://ai-orchestrator:8000" + mock_settings.orchestrator_url = "http://ai-orchestrator:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" orchestrate("job-1", "user-1", "cv text", "cv_review") headers = _get_headers_from_call(mock_client, "post") @@ -153,7 +158,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.validator_url = "http://output-validator:8000" + mock_settings.validator_url = "http://output-validator:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY validate("job-1", "cv_review_v1", {}) headers = _get_headers_from_call(mock_client, "post") @@ -165,7 +170,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.validator_url = "http://output-validator:8000" + mock_settings.validator_url = "http://output-validator:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" validate("job-1", "cv_review_v1", {}) headers = _get_headers_from_call(mock_client, "post") @@ -182,7 +187,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.status_updater.httpx.Client", return_value=mock_client), patch("app.services.status_updater.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY update_status("job-1", "complete") headers = _get_headers_from_call(mock_client, "patch") @@ -194,7 +199,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.status_updater.httpx.Client", return_value=mock_client), patch("app.services.status_updater.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" update_status("job-1", "complete") headers = _get_headers_from_call(mock_client, "patch") @@ -211,7 +216,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.result_storer.httpx.Client", return_value=mock_client), patch("app.services.result_storer.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY store_result("job-1", '{"score": 75}') headers = _get_headers_from_call(mock_client, "put") @@ -223,7 +228,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.result_storer.httpx.Client", return_value=mock_client), patch("app.services.result_storer.settings") as mock_settings, ): - mock_settings.core_api_base_url = "http://core-api:5000" + mock_settings.core_api_base_url = "http://core-api:5000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" store_result("job-1", '{"score": 75}') headers = _get_headers_from_call(mock_client, "put") @@ -246,7 +251,7 @@ def test_sends_header_when_key_configured(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.renderer_url = "http://document-renderer:8000" + mock_settings.renderer_url = "http://document-renderer:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = TEST_KEY render_cv("job-1", "cv.docx", "corporate", {}, "note", []) headers = _get_headers_from_call(mock_client, "post") @@ -258,7 +263,7 @@ def test_omits_header_when_key_empty(self): patch("app.services.internal_service_client.httpx.Client", return_value=mock_client), patch("app.services.internal_service_client.settings") as mock_settings, ): - mock_settings.renderer_url = "http://document-renderer:8000" + mock_settings.renderer_url = "http://document-renderer:8000" # NOSONAR: S5332 - test-only mock value mock_settings.internal_api_key = "" render_cv("job-1", "cv.docx", "corporate", {}, "note", []) headers = _get_headers_from_call(mock_client, "post")