Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,8 +17,15 @@ class ManagementSDKInitiator {
}

async createAPIClient(config): Promise<ContentstackClient> {
// 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,
Expand Down
23 changes: 20 additions & 3 deletions packages/contentstack-utilities/src/http-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
136 changes: 133 additions & 3 deletions packages/contentstack-utilities/src/proxy-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<string, string | undefined> = {};
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
Expand Down
Loading