diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index 2307d48902..6c83843da9 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -15,4 +15,4 @@ jobs: args: --all-projects --fail-on=all json: true continue-on-error: true - - uses: contentstack/sca-policy@main + - uses: contentstack/sca-policy@main diff --git a/packages/contentstack-utilities/src/contentstack-management-sdk.ts b/packages/contentstack-utilities/src/contentstack-management-sdk.ts index 5306e9eb21..77600cc31f 100644 --- a/packages/contentstack-utilities/src/contentstack-management-sdk.ts +++ b/packages/contentstack-utilities/src/contentstack-management-sdk.ts @@ -2,7 +2,7 @@ import { client, ContentstackClient, ContentstackConfig } from '@contentstack/ma import authHandler from './auth-handler'; import { Agent } from 'node:https'; import configHandler, { default as configStore } from './config-handler'; -import { getProxyConfig } from './proxy-helper'; +import { getProxyConfigForHost, resolveRequestHost, clearProxyEnv } from './proxy-helper'; import dotenv from 'dotenv'; dotenv.config(); @@ -17,8 +17,15 @@ class ManagementSDKInitiator { } async createAPIClient(config): Promise { - // Get proxy configuration with priority: Environment variables > Global config - const proxyConfig = getProxyConfig(); + // Resolve host so NO_PROXY applies even when config.host is omitted (e.g. from region.cma) + const host = resolveRequestHost(config); + // NO_PROXY has priority over HTTP_PROXY/HTTPS_PROXY and config-set proxy + const proxyConfig = getProxyConfigForHost(host); + + // When bypassing, clear proxy env immediately so SDK never see it (they may read at init or first request). + if (!proxyConfig) { + clearProxyEnv(); + } const option: ContentstackConfig = { host: config.host, diff --git a/packages/contentstack-utilities/src/http-client/client.ts b/packages/contentstack-utilities/src/http-client/client.ts index a7b1013545..2fd213d2ed 100644 --- a/packages/contentstack-utilities/src/http-client/client.ts +++ b/packages/contentstack-utilities/src/http-client/client.ts @@ -3,7 +3,23 @@ import { IHttpClient } from './client-interface'; import { HttpResponse } from './http-response'; import configStore from '../config-handler'; import authHandler from '../auth-handler'; -import { hasProxy, getProxyUrl, getProxyConfig } from '../proxy-helper'; +import { hasProxy, getProxyUrl, getProxyConfig, getProxyConfigForHost } from '../proxy-helper'; + +/** + * Derive request host from baseURL or url for NO_PROXY checks. + */ +function getRequestHost(baseURL?: string, url?: string): string | undefined { + const toTry = [baseURL, url].filter(Boolean) as string[]; + for (const candidateUrl of toTry) { + try { + const parsed = new URL(candidateUrl.startsWith('http') ? candidateUrl : `https://${candidateUrl}`); + return parsed.hostname || undefined; + } catch { + // Invalid URL; try next candidate (baseURL or url) + } + } + return undefined; +} export type HttpClientOptions = { disableEarlyAccessHeaders?: boolean; @@ -411,9 +427,10 @@ export class HttpClient implements IHttpClient { } } - // Configure proxy if available (priority: request.proxy > getProxyConfig()) + // Configure proxy if available. NO_PROXY has priority: hosts in NO_PROXY never use proxy. if (!this.request.proxy) { - const proxyConfig = getProxyConfig(); + const host = getRequestHost(this.request.baseURL, url); + const proxyConfig = host ? getProxyConfigForHost(host) : getProxyConfig(); if (proxyConfig) { this.request.proxy = proxyConfig; } diff --git a/packages/contentstack-utilities/src/proxy-helper.ts b/packages/contentstack-utilities/src/proxy-helper.ts index 708b097fc6..68de79fc8d 100644 --- a/packages/contentstack-utilities/src/proxy-helper.ts +++ b/packages/contentstack-utilities/src/proxy-helper.ts @@ -11,11 +11,84 @@ export interface ProxyConfig { } /** - * Get proxy configuration with priority: Environment variables > Global config + * Parse NO_PROXY / no_proxy env (both uppercase and lowercase). + * NO_PROXY has priority over HTTP_PROXY/HTTPS_PROXY: hosts in this list never use the proxy. + * Values are hostnames only, comma-separated; leading dot matches subdomains (e.g. .contentstack.io). + * The bypass list is fully dynamic: only env values are used (no hardcoded default). + * @returns List of trimmed entries, or empty array when NO_PROXY/no_proxy is unset + */ +export function getNoProxyList(): string[] { + const raw = process.env.NO_PROXY || process.env.no_proxy || ''; + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Normalize host for NO_PROXY matching: strip protocol/URL, port, lowercase, handle IPv6 brackets. + * Accepts hostname, host:port, or full URL (e.g. https://api.contentstack.io). + */ +function normalizeHost(host: string): string { + if (!host || typeof host !== 'string') return ''; + let h = host.trim().toLowerCase(); + // If it looks like a URL, extract hostname so NO_PROXY matching works (e.g. region.cma is full URL) + if (h.includes('://')) { + try { + const u = new URL(h); + h = u.hostname; + } catch { + // fall through to port stripping below + } + } + const portIdx = h.lastIndexOf(':'); + if (h.startsWith('[')) { + const close = h.indexOf(']'); + if (close !== -1 && h.length > close + 1 && h[close + 1] === ':') { + h = h.slice(1, close); + } + } else if (portIdx !== -1) { + const after = h.slice(portIdx + 1); + if (/^\d+$/.test(after)) { + h = h.slice(0, portIdx); + } + } + return h; +} + +/** + * Check if the given host should bypass the proxy based on NO_PROXY / no_proxy. + * Supports: exact host, leading-dot subdomain match (e.g. .contentstack.io), and wildcard *. + * @param host - Request hostname (with or without port; will be normalized) + * @returns true if proxy should not be used for this host + */ +export function shouldBypassProxy(host: string): boolean { + const normalized = normalizeHost(host); + if (!normalized) return false; + + const list = getNoProxyList(); + for (const entry of list) { + const e = entry.trim().toLowerCase(); + if (!e) continue; + if (e === '*') return true; + if (e.startsWith('.')) { + const domain = e.slice(1); + if (normalized === domain || normalized.endsWith(e)) return true; + } else { + if (normalized === e) return true; + } + } + return false; +} + +/** + * Get proxy configuration. Sources (in order): env (HTTP_PROXY/HTTPS_PROXY), then global config + * from `csdx config:set:proxy --host --port --protocol `. + * For per-request use, prefer getProxyConfigForHost(host) so NO_PROXY overrides both sources. * @returns ProxyConfig object or undefined if no proxy is configured */ export function getProxyConfig(): ProxyConfig | undefined { - // Priority 1: Check environment variables (HTTPS_PROXY or HTTP_PROXY) + // Priority 1: Environment variables (HTTPS_PROXY or HTTP_PROXY) const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; if (proxyUrl) { @@ -46,7 +119,7 @@ export function getProxyConfig(): ProxyConfig | undefined { } } - // Priority 2: Check global config store + // Priority 2: Global config (csdx config:set:proxy) const globalProxyConfig = configStore.get('proxy'); if (globalProxyConfig) { if (typeof globalProxyConfig === 'object') { @@ -86,6 +159,63 @@ export function getProxyConfig(): ProxyConfig | undefined { return undefined; } +/** + * Get proxy config only when the request host is not in NO_PROXY. + * NO_PROXY has priority over both HTTP_PROXY/HTTPS_PROXY and over proxy set via + * `csdx config:set:proxy` — if the host matches NO_PROXY, no proxy is used. + * Use this for all outbound requests so Contentstack and localhost bypass the proxy when set. + * @param host - Request hostname (e.g. api.contentstack.io or full URL like https://api.contentstack.io) + * @returns ProxyConfig or undefined if proxy is disabled or host should bypass (NO_PROXY) + */ +export function getProxyConfigForHost(host: string): ProxyConfig | undefined { + if (shouldBypassProxy(host)) return undefined; + return getProxyConfig(); +} + +/** + * Resolve request host for proxy/NO_PROXY checks: config.host or default CMA from region. + * Use when the caller may omit host so NO_PROXY still applies (e.g. from region.cma). + * @param config - Object with optional host (e.g. API client config) + * @returns Host string (hostname or empty) + */ +export function resolveRequestHost(config: { host?: string }): string { + if (config.host) return config.host; + const cma = configStore.get('region')?.cma; + if (cma && typeof cma === 'string') { + if (cma.startsWith('http')) { + try { + const u = new URL(cma); + return u.hostname || cma; + } catch { + return cma; + } + } + return cma; + } + return ''; +} + +/** + * Temporarily clear proxy-related env vars so SDK/axios cannot use them. + * Call the returned function to restore. Use when creating a client for a host in NO_PROXY. + * @returns Restore function (call to put env back) + */ +export function clearProxyEnv(): () => void { + const saved: Record = {}; + const keys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']; + for (const k of keys) { + if (k in process.env) { + saved[k] = process.env[k]; + delete process.env[k]; + } + } + return () => { + for (const k of keys) { + if (saved[k] !== undefined) process.env[k] = saved[k]; + } + }; +} + /** * Check if proxy is configured (from any source) * @returns true if proxy is configured, false otherwise