Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion .github/workflows/sca-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 <host> --port <port> --protocol <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