diff --git a/apps/server-nestjs/src/config/argocd.config.ts b/apps/server-nestjs/src/config/argocd.config.ts index ab6d413109..e020bf4ca9 100644 --- a/apps/server-nestjs/src/config/argocd.config.ts +++ b/apps/server-nestjs/src/config/argocd.config.ts @@ -1,11 +1,11 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { csv, truthySchema } from './config.utils' +import { csv, truthySchema, urlSchema } from './config.utils' const argocdFeatureSchema = z.object({ ARGO_NAMESPACE: z.string().default('argocd'), - ARGOCD_URL: z.string().url(), - ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_URL: urlSchema, + ARGOCD_INTERNAL_URL: urlSchema.optional(), ARGOCD_EXTRA_REPOSITORIES: csv(z.string()), DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), diff --git a/apps/server-nestjs/src/config/base.config.ts b/apps/server-nestjs/src/config/base.config.ts index 60e40d700c..de37f13686 100644 --- a/apps/server-nestjs/src/config/base.config.ts +++ b/apps/server-nestjs/src/config/base.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { flag, truthySchema } from './config.utils' +import { flag, truthySchema, urlSchema } from './config.utils' const baseFeatureSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), @@ -8,7 +8,7 @@ const baseFeatureSchema = z.object({ SERVER_HOST: z.string().default('localhost'), SERVER_PORT: z.string().transform(Number).default('0'), APP_VERSION: z.string().default('unknown'), - DB_URL: z.string().url(), + DB_URL: urlSchema, PROJECTS_ROOT_DIR: z.string().default(''), }).transform((raw) => { return { diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index 155c025349..166a76ee9b 100644 --- a/apps/server-nestjs/src/config/config.utils.spec.ts +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import z from 'zod' -import { csv, flag, truthySchema } from './config.utils' +import { csv, flag, truthySchema, urlSchema } from './config.utils' describe('config.utils', () => { describe('flag', () => { @@ -23,6 +23,18 @@ describe('config.utils', () => { }) }) + describe('urlSchema', () => { + it('normalizes and strips the trailing slash', () => { + expect(urlSchema.parse('https://gitlab.internal/')).toBe('https://gitlab.internal') + expect(urlSchema.parse('HTTPS://GITLAB.INTERNAL:443')).toBe('https://gitlab.internal') + }) + + it('keeps path segments and rejects invalid urls', () => { + expect(urlSchema.parse('https://gitlab.internal/api/')).toBe('https://gitlab.internal/api') + expect(() => urlSchema.parse('not-a-url')).toThrow() + }) + }) + describe('csv', () => { const schema = csv(z.string()) diff --git a/apps/server-nestjs/src/config/config.utils.ts b/apps/server-nestjs/src/config/config.utils.ts index fd7ac69fd4..4410d8bf59 100644 --- a/apps/server-nestjs/src/config/config.utils.ts +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -67,6 +67,19 @@ export const cronSchema = z } }) +// URL env var: validates, then normalizes (case, dot-segments, default port) +// and strips the root trailing slash that new URL() keeps — consumers join +// paths with '/', so a trailing slash would yield double slashes. +export const urlSchema = z + .string() + .url() + .transform((s) => { + const href = new URL(s).href + let end = href.length + while (end > 0 && href[end - 1] === '/') end-- + return href.slice(0, end) + }) + // Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. export const truthySchema = z.enum(['true', 'false', '1', '0']) diff --git a/apps/server-nestjs/src/config/gitlab.config.spec.ts b/apps/server-nestjs/src/config/gitlab.config.spec.ts index 6b973746ed..882f466137 100644 --- a/apps/server-nestjs/src/config/gitlab.config.spec.ts +++ b/apps/server-nestjs/src/config/gitlab.config.spec.ts @@ -8,8 +8,8 @@ describe('gitlabConfig', () => { it('parses a full config', () => { vi.stubEnv('GITLAB_TOKEN', 'token') - vi.stubEnv('GITLAB_URL', 'https://gitlab.internal') - vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080') + vi.stubEnv('GITLAB_URL', 'https://gitlab.internal/') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080/') vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') expect(gitlabConfigFactory()).toMatchObject({ @@ -24,7 +24,7 @@ describe('gitlabConfig', () => { it('falls back to public url when internal url is absent', () => { vi.stubEnv('GITLAB_TOKEN', 'token') - vi.stubEnv('GITLAB_URL', 'https://gitlab.internal') + vi.stubEnv('GITLAB_URL', 'https://gitlab.internal/') vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') const cfg = gitlabConfigFactory() diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts index 9367175272..b5374674ad 100644 --- a/apps/server-nestjs/src/config/gitlab.config.ts +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -1,11 +1,11 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { truthySchema } from './config.utils' +import { truthySchema, urlSchema } from './config.utils' const gitlabFeatureSchema = z.object({ GITLAB_TOKEN: z.string().min(1), - GITLAB_URL: z.string().url(), - GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_URL: urlSchema, + GITLAB_INTERNAL_URL: urlSchema.optional(), GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(365), GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), PROJECTS_ROOT_DIR: z.string().min(1), diff --git a/apps/server-nestjs/src/config/harbor.config.ts b/apps/server-nestjs/src/config/harbor.config.ts index 94395a1c1a..74532c4c8f 100644 --- a/apps/server-nestjs/src/config/harbor.config.ts +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -1,6 +1,6 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { cronSchema } from './config.utils' +import { cronSchema, urlSchema } from './config.utils' const ruleTemplateSchema = z.enum([ 'always', @@ -13,8 +13,8 @@ const ruleTemplateSchema = z.enum([ export type RuleTemplate = z.infer const harborFeatureSchema = z.object({ - HARBOR_URL: z.string().url(), - HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_URL: urlSchema, + HARBOR_INTERNAL_URL: urlSchema.optional(), HARBOR_ADMIN: z.string().min(1), HARBOR_ADMIN_PASSWORD: z.string().min(1), HARBOR_RULE_TEMPLATE: ruleTemplateSchema.optional(), diff --git a/apps/server-nestjs/src/config/keycloak.config.ts b/apps/server-nestjs/src/config/keycloak.config.ts index cb917e6aa2..dc1b7c836f 100644 --- a/apps/server-nestjs/src/config/keycloak.config.ts +++ b/apps/server-nestjs/src/config/keycloak.config.ts @@ -1,5 +1,6 @@ import { registerAs } from '@nestjs/config' import z from 'zod' +import { urlSchema } from './config.utils' const keycloakFeatureSchema = z.object({ KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), @@ -10,7 +11,7 @@ const keycloakFeatureSchema = z.object({ KEYCLOAK_ADMIN: z.string().min(1), KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), - KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_REDIRECT_URI: urlSchema, KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), diff --git a/apps/server-nestjs/src/config/nexus.config.ts b/apps/server-nestjs/src/config/nexus.config.ts index 7be7712358..e094224619 100644 --- a/apps/server-nestjs/src/config/nexus.config.ts +++ b/apps/server-nestjs/src/config/nexus.config.ts @@ -1,10 +1,10 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { truthySchema } from './config.utils' +import { truthySchema, urlSchema } from './config.utils' const nexusFeatureSchema = z.object({ - NEXUS_URL: z.string().url(), - NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_URL: urlSchema, + NEXUS_INTERNAL_URL: urlSchema.optional(), NEXUS_ADMIN: z.string().min(1), NEXUS_ADMIN_PASSWORD: z.string().min(1), NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), diff --git a/apps/server-nestjs/src/config/observability.config.ts b/apps/server-nestjs/src/config/observability.config.ts index 45db682481..ac5a8c574d 100644 --- a/apps/server-nestjs/src/config/observability.config.ts +++ b/apps/server-nestjs/src/config/observability.config.ts @@ -1,8 +1,9 @@ import { registerAs } from '@nestjs/config' import z from 'zod' +import { urlSchema } from './config.utils' const observabilityFeatureSchema = z.object({ - GRAFANA_URL: z.string().url(), + GRAFANA_URL: urlSchema, DSO_OBSERVABILITY_CHART_VERSION: z.string().min(1), }).transform(raw => ({ grafanaUrl: raw.GRAFANA_URL, diff --git a/apps/server-nestjs/src/config/service-chain.config.ts b/apps/server-nestjs/src/config/service-chain.config.ts index b5f0a9373d..7a32426f36 100644 --- a/apps/server-nestjs/src/config/service-chain.config.ts +++ b/apps/server-nestjs/src/config/service-chain.config.ts @@ -1,10 +1,10 @@ import { registerAs } from '@nestjs/config' import z from 'zod' -import { truthySchema } from './config.utils' +import { truthySchema, urlSchema } from './config.utils' const serviceChainFeatureSchema = z.object({ - OPENCDS_URL: z.string().url(), - OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_URL: urlSchema, + OPENCDS_INTERNAL_URL: urlSchema.optional(), OPENCDS_API_TOKEN: z.string().min(1), OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('true').transform(v => v === 'true' || v === '1'), }).transform((raw) => { diff --git a/apps/server-nestjs/src/config/sonarqube.config.ts b/apps/server-nestjs/src/config/sonarqube.config.ts index f61cd9eecf..7d34026f43 100644 --- a/apps/server-nestjs/src/config/sonarqube.config.ts +++ b/apps/server-nestjs/src/config/sonarqube.config.ts @@ -1,9 +1,10 @@ import { registerAs } from '@nestjs/config' import z from 'zod' +import { urlSchema } from './config.utils' const sonarqubeFeatureSchema = z.object({ - SONARQUBE_URL: z.string().url(), - SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONARQUBE_URL: urlSchema, + SONARQUBE_INTERNAL_URL: urlSchema.optional(), SONAR_API_TOKEN: z.string().min(1), }).transform(raw => ({ url: raw.SONARQUBE_URL, diff --git a/apps/server-nestjs/src/config/vault.config.ts b/apps/server-nestjs/src/config/vault.config.ts index ea73971526..ecfb9e473d 100644 --- a/apps/server-nestjs/src/config/vault.config.ts +++ b/apps/server-nestjs/src/config/vault.config.ts @@ -1,10 +1,11 @@ import { registerAs } from '@nestjs/config' import z from 'zod' +import { urlSchema } from './config.utils' const vaultFeatureSchema = z.object({ VAULT_TOKEN: z.string().min(1), - VAULT_URL: z.string().url(), - VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_URL: urlSchema, + VAULT_INTERNAL_URL: urlSchema.optional(), VAULT_KV_NAME: z.string().default('forge-dso'), }).transform(raw => ({ token: raw.VAULT_TOKEN, diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index a53183e093..43cb9d3b53 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -218,7 +218,7 @@ export class GitlabClientService { const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) const urlBase = this.config.internalUrl ?? this.config.url if (!urlBase) throw new Error('GITLAB_URL is required') - return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + return new URL(`${projectGroup.full_path}/${repoName}.git`, urlBase).toString() } private async getOrCreateRepo(subGroupPath: string, ciConfigPath?: string) { diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts index ee824bd98d..5ba59489f6 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -5,12 +5,13 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' import { JwtSecretRequestType } from '@nestjs/jwt' import { z } from 'zod' +import { urlSchema } from '../../../../config/config.utils' import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ - issuer: z.string().url(), - jwks_uri: z.string().url(), + issuer: urlSchema, + jwks_uri: urlSchema, }) type OpenidConfiguration = z.infer diff --git a/apps/server-nestjs/src/modules/observability/observability.utils.ts b/apps/server-nestjs/src/modules/observability/observability.utils.ts index 14f62df357..a4ee732433 100644 --- a/apps/server-nestjs/src/modules/observability/observability.utils.ts +++ b/apps/server-nestjs/src/modules/observability/observability.utils.ts @@ -1,6 +1,7 @@ import type { ProjectWithDetails } from './observability-datastore.service' import { specificallyDisabled } from '@cpn-console/hooks' import { compressUUID, getPermsByUserRoles, ProjectAuthorized } from '@cpn-console/shared' +import { urlSchema } from '../../config/config.utils' import { stringify } from 'yaml' import z from 'zod' import { @@ -201,7 +202,7 @@ const observabilityChartSchema = z.object({ dependencies: z.array(z.object({ name: z.string(), version: z.string(), - repository: z.string().url(), + repository: urlSchema, })), })