Skip to content

Instantly share code, notes, and snippets.

@jtbr
Created February 18, 2026 16:07
Show Gist options
  • Select an option

  • Save jtbr/d1b8b144edc9096463962ce983023a9d to your computer and use it in GitHub Desktop.

Select an option

Save jtbr/d1b8b144edc9096463962ce983023a9d to your computer and use it in GitHub Desktop.
Simple CORS proxy
'use strict';
const { log } = require('console');
// CORS proxy intended for local use to call 3rd-party APIs;
// redirects requests to the target URL to avoid CORS issues with strict origin.
//
// To run: `node cors-proxy.js`
// Then request http://localhost:8888/?q=https://example.com/api to proxy a request to example.com/api
//
// "localhost" and "8888" can be configured with environment variables HOST and PORT.
// The target URL can also be passed with encodeURIComponent(url) as the q parameter.
//
// Query parameters (q must be the last parameter):
// q=URL Target URL (required, must be last)
// referer=MODE Referrer mode override: [same-site] or none
// backend=BE Backend override: [http2] or curl
// force=1 Force refresh override (skip cached results) [default 0]
//
// Only https is supported, using http/2 (this should work for essentially any modern server)
// For sites that block automated requests using TLS fingerprints, make
// [curl-impersonate-chrome](https://github.com/lwthiker/curl-impersonate) available on the path
// and either use BACKEND=curl to make it the default, or specify it in each request ("?backend=curl")
//
// Advanced usage:
// Run: `BACKEND=curl PORT=3990 node cors-proxy.js`
// Request using overrides: http://localhost:3990/?referer=none&force=1&q=https://example.com/api
var http = require('http');
var http2 = require('http2');
var spawn = require('child_process').spawn;
var PassThrough = require('stream').PassThrough;
// Global default options are specified via environment variables
var host = process.env.HOST || '127.0.0.1';
var port = process.env.PORT || 8888;
var forceRefresh = !!process.env.FORCE_REFRESH; // don't allow cached results
// REFERER_MODE: 'same-site' (default) fakes an in-page API call;
// 'none' drops origin/referer entirely.
var refererMode = (process.env.REFERER_MODE || 'same-site').toLowerCase();
// BACKEND: 'http2' (default) uses Node's http2 module with session pooling. Works for most sites.
// 'curl' uses curl-impersonate-chrome for browser-like TLS fingerprints. Needed for tricky sites.
// 'curl-impersonate-chrome' must be on the path.
var backend = (process.env.BACKEND || 'http2').toLowerCase();
// ---------------------------------------------------------------------------
// URL parsing
// ---------------------------------------------------------------------------
function parseTargetUrl(url) {
// Accept optionally encodeURIComponent-encoded URLs
if (/^https?%3A/i.test(url)) {
try { url = decodeURIComponent(url); } catch (e) { /* keep original */ }
}
try {
var u = new URL(url);
if (u.protocol !== 'https:') return null;
return {
protocol: 'https',
host: u.host,
rawPath: u.pathname + u.search,
origin: u.origin,
href: u.href,
};
} catch (e) { return null; }
}
// ---------------------------------------------------------------------------
// Request parsing — extract target URL and per-request options from query params
// ---------------------------------------------------------------------------
function parseRequest(reqUrl) {
var qIdx = reqUrl.indexOf('?');
if (qIdx === -1) return null;
var qs = reqUrl.substring(qIdx + 1);
// q= must be present and must be the last parameter
var qMatch = qs.match(/(?:^|&)q=([\s\S]+)$/);
if (!qMatch) return null;
var target = parseTargetUrl(qMatch[1]);
if (!target) return null;
// Parse other params (everything before q=)
var otherParams = qs.substring(0, qMatch.index || 0);
// use global defaults unless specified
var opts = {
refererMode: refererMode,
forceRefresh: forceRefresh,
backend: backend,
};
if (otherParams) {
otherParams.split('&').forEach(function(pair) {
if (!pair) return;
var eq = pair.indexOf('=');
if (eq === -1) return;
var key = pair.substring(0, eq);
var val = pair.substring(eq + 1);
if (key === 'referer') opts.refererMode = val.toLowerCase();
else if (key === 'backend') opts.backend = val.toLowerCase();
else if (key === 'force') opts.forceRefresh = (val === '1' || val === 'true');
});
}
return { target: target, opts: opts };
}
// ---------------------------------------------------------------------------
// Header helpers
// ---------------------------------------------------------------------------
// Request headers to never forward to the target
var DROP_HEADERS = new Set([
'host', // replaced by :authority in HTTP/2
'connection', // hop-by-hop, invalid in HTTP/2
'upgrade', // hop-by-hop
'http2-settings', // hop-by-hop
'transfer-encoding', // HTTP/2 handles framing
'proxy-connection', // non-standard hop-by-hop
'keep-alive', // hop-by-hop
'origin', // will be forged to match target
'referer', // will be forged to match target
'x-requested-with', // fingerprint: signals AJAX/scripted request
]);
// In particular, user-agent is forwarded unless it is added to the above set.
// With FORCE_REFRESH=1, strip conditional caching headers to always get fresh responses
var CACHE_HEADERS = new Set([
'if-modified-since', 'if-none-match', 'if-unmodified-since',
'if-match', 'if-range',
]);
// drop sec-ch client hints but keep sec-fetch-site, sec-fetch-mode, sec-fetch-dest
var DROP_PREFIXES = ['sec-ch-', 'x-forwarded-'];
function shouldDrop(name, reqForceRefresh) {
if (DROP_HEADERS.has(name)) return true;
if (reqForceRefresh && CACHE_HEADERS.has(name)) return true;
for (var i = 0; i < DROP_PREFIXES.length; i++) {
if (name.startsWith(DROP_PREFIXES[i])) return true;
}
return false;
}
function buildOutgoingHeaders(req, target, opts) {
var out = {};
Object.keys(req.headers).forEach(function(name) {
if (!shouldDrop(name.toLowerCase(), opts.forceRefresh)) {
out[name.toLowerCase()] = req.headers[name];
}
});
if (opts.refererMode === 'same-site') {
// Look like an in-page API call from the target site
out['referer'] = target.origin + '/';
out['sec-fetch-site'] = 'same-origin';
if (req.method !== 'GET' && req.method !== 'HEAD') {
out['origin'] = target.origin;
}
} else {
// 'none' — drop origin/referer; only forge for state-changing methods
if (req.method !== 'GET' && req.method !== 'HEAD') {
out['origin'] = target.origin;
out['referer'] = target.href;
}
}
return out;
}
function addCorsHeaders(headers, reqOrigin) {
if (reqOrigin && reqOrigin !== 'null') {
headers['access-control-allow-origin'] = reqOrigin;
headers['access-control-allow-credentials'] = 'true';
} else {
headers['access-control-allow-origin'] = '*';
}
headers['access-control-expose-headers'] = Object.keys(headers).join(',');
}
function rewriteCookies(setCookie) {
if (!setCookie) return null;
if (!Array.isArray(setCookie)) setCookie = [setCookie];
return setCookie.map(function(c) {
return c
.replace(/\s*Domain=[^;]*;?/gi, '')
.replace(/\s*Path=[^;]*;?/gi, ' Path=/;')
.replace(/\s*Secure;?/gi, '')
.replace(/\s*SameSite=[^;]*;?/gi, '');
});
}
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function formatSize(bytes) {
if (bytes == null || bytes === '') return '-';
var n = parseInt(bytes, 10);
if (isNaN(n)) return '-';
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
return n + ' B';
}
// log non-default options per request
function logOptsPrefix(opts) {
var prefix = ''
if (opts.backend !== backend)
prefix += '[backend: ' + opts.backend + ']';
if (opts.forceRefresh !== forceRefresh)
prefix += '[forceRefresh: ' + opts.forceRefresh + ']';
if (opts.refererMode !== refererMode)
prefix += '[refererMode: ' + opts.refererMode + ']';
return prefix
}
function logResponse(url, status, headers, actualBytes, prefix) {
var type = (headers['content-type'] || '-').split(';')[0].trim();
var size = actualBytes != null ? formatSize(String(actualBytes))
: formatSize(headers['content-length']);
var time = new Date().toLocaleTimeString('uk'); // eg '17:37' (local time)
console.log('[' + time + '] ' + prefix + ' ' + status + ' ' + url + ' (' + type + ', ' + size + ')');
}
// ---------------------------------------------------------------------------
// HTTP/2 session cache — reuse connections per origin
// ---------------------------------------------------------------------------
var sessions = {};
function getSession(origin) {
var s = sessions[origin];
if (s && !s.closed && !s.destroyed) return s;
s = http2.connect(origin);
s.on('error', function() { delete sessions[origin]; });
s.on('close', function() { delete sessions[origin]; });
s.on('goaway', function() { delete sessions[origin]; });
s.setTimeout(60000, function() { s.close(); });
sessions[origin] = s;
return s;
}
// ---------------------------------------------------------------------------
// Core proxy — shared response handling, dispatches to http2 or curl backend
// ---------------------------------------------------------------------------
function proxyRequest(req, res, target, redirects, originalHref, opts) {
if (!originalHref) originalHref = target.href;
var headers = buildOutgoingHeaders(req, target, opts);
const logPrefix = logOptsPrefix(opts);
function onResponse(status, resHeaders, bodyStream, close) {
// --- Redirect handling ---
var isRedirect = status === 301 || status === 302 || status === 303 ||
status === 307 || status === 308;
if (isRedirect && resHeaders.location && redirects < 5) {
logResponse(target.href, status, resHeaders, logPrefix);
var nextUrl;
try { nextUrl = new URL(resHeaders.location, target.href).href; }
catch (e) { nextUrl = null; }
var nextTarget = nextUrl ? parseTargetUrl(nextUrl) : null;
if (nextTarget && nextTarget.protocol === 'https') {
var sc = resHeaders['set-cookie'];
if (sc) {
var parts = req.headers.cookie ? [req.headers.cookie] : [];
(Array.isArray(sc) ? sc : [sc]).forEach(function(c) {
parts.push(c.split(';')[0]);
});
req.headers.cookie = parts.join('; ');
}
if (status <= 303) req.method = 'GET';
if (close) close();
bodyStream.resume();
proxyRequest(req, res, nextTarget, redirects + 1, originalHref, opts);
return;
}
}
// --- Build final response ---
var cookies = rewriteCookies(resHeaders['set-cookie']);
if (cookies) resHeaders['set-cookie'] = cookies;
resHeaders['x-request-url'] = originalHref;
resHeaders['x-final-url'] = target.href;
addCorsHeaders(resHeaders, req.headers.origin);
res.writeHead(status, resHeaders);
// Track actual bytes and log when transfer completes
var bytes = 0;
bodyStream.on('data', function(chunk) { bytes += chunk.length; });
bodyStream.on('end', function() {
logResponse(target.href, status, resHeaders, bytes, logPrefix);
});
bodyStream.pipe(res);
}
function onError(err) {
if (!res.headersSent) {
var eh = { 'content-type': 'text/plain' };
addCorsHeaders(eh, req.headers.origin);
res.writeHead(err.statusCode || 502, eh);
res.end(err.message);
}
}
if (opts.backend === 'curl') {
fetchViaCurl(req, target, headers, onResponse, onError);
} else {
fetchViaHttp2(req, target, headers, onResponse, onError);
}
}
// ---------------------------------------------------------------------------
// Backend: Node http2
// ---------------------------------------------------------------------------
function fetchViaHttp2(req, target, headers, onResponse, onError) {
headers[':method'] = req.method;
headers[':path'] = target.rawPath;
headers[':scheme'] = target.protocol;
headers[':authority'] = target.host;
var session;
try {
session = getSession(target.origin);
} catch (err) {
onError(new Error('Failed to connect to ' + target.origin + ': ' + err.message));
return;
}
var stream = session.request(headers);
stream.setTimeout(30000, function() {
stream.close();
var err = new Error('Upstream timeout: ' + target.href);
err.statusCode = 504;
onError(err);
});
stream.on('response', function(h2headers) {
var status = h2headers[':status'];
var resHeaders = {};
Object.keys(h2headers).forEach(function(k) {
if (k[0] !== ':') resHeaders[k] = h2headers[k];
});
onResponse(status, resHeaders, stream, function() { stream.close(); });
});
stream.on('error', onError);
if (req.method !== 'GET' && req.method !== 'HEAD') {
req.pipe(stream);
} else {
stream.end();
}
}
// ---------------------------------------------------------------------------
// Backend: curl-impersonate (browser-like TLS fingerprint)
// ---------------------------------------------------------------------------
function fetchViaCurl(req, target, headers, onResponse, onError) {
var args = ['-sS', '-i', '--max-redirs', '0',
'--connect-timeout', '10', '--max-time', '30'];
Object.keys(headers).forEach(function(name) {
args.push('-H', name + ': ' + headers[name]);
});
if (req.method !== 'GET') {
args.push('-X', req.method);
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
args.push('--data-binary', '@-');
}
args.push(target.href);
var proc = spawn('curl-impersonate-chrome', args);
var headerBuf = Buffer.alloc(0);
var headersParsed = false;
var body = new PassThrough();
proc.stdout.on('data', function(chunk) {
if (headersParsed) {
body.write(chunk);
return;
}
headerBuf = Buffer.concat([headerBuf, chunk]);
var idx = headerBuf.indexOf('\r\n\r\n');
if (idx === -1) return;
headersParsed = true;
var headerStr = headerBuf.slice(0, idx).toString();
var remaining = headerBuf.slice(idx + 4);
var lines = headerStr.split('\r\n');
var status = parseInt(lines[0].split(/\s+/)[1], 10);
var resHeaders = {};
for (var i = 1; i < lines.length; i++) {
var colon = lines[i].indexOf(':');
if (colon === -1) continue;
var name = lines[i].substring(0, colon).trim().toLowerCase();
var value = lines[i].substring(colon + 1).trim();
// Collect multiple set-cookie headers as an array
if (name === 'set-cookie') {
if (!resHeaders[name]) resHeaders[name] = [];
resHeaders[name].push(value);
} else {
resHeaders[name] = value;
}
}
onResponse(status, resHeaders, body, function() { proc.kill(); });
if (remaining.length > 0) body.write(remaining);
});
proc.stdout.on('end', function() {
body.end();
});
proc.on('error', function(err) {
if (!headersParsed) onError(err);
else body.destroy(err);
});
proc.on('close', function(code) {
if (!headersParsed && code !== 0) {
onError(new Error('curl-impersonate exited with code ' + code));
}
});
if (req.method !== 'GET' && req.method !== 'HEAD') {
req.pipe(proc.stdin);
} else {
proc.stdin.end();
}
}
// ---------------------------------------------------------------------------
// Local HTTP server (browser-facing)
// ---------------------------------------------------------------------------
var server = http.createServer(function(req, res) {
// CORS preflight
if (req.method === 'OPTIONS') {
var ph = {};
addCorsHeaders(ph, req.headers.origin);
if (req.headers['access-control-request-method'])
ph['access-control-allow-methods'] = req.headers['access-control-request-method'];
if (req.headers['access-control-request-headers'])
ph['access-control-allow-headers'] = req.headers['access-control-request-headers'];
ph['access-control-max-age'] = '86400';
res.writeHead(200, ph);
res.end();
return;
}
// Parse the target URL and per-request options from query parameters
var parsed = parseRequest(req.url);
if (!parsed) {
var base = 'http://' + (host === '0.0.0.0' || host === '127.0.0.1' ? 'localhost' : host) + ':' + port;
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(
'CORS Proxy (HTTP/2)\n\n' +
'Usage:\n' +
' ' + base + '/?q=https://example.com/path\n\n' +
'Parameters (q must be last):\n' +
' q=URL Target URL (required, must be last parameter)\n' +
' referer=MODE Referrer mode: same-site (default) or none\n' +
' backend=BE Backend: http2 (default) or curl\n' +
' force=1 Force refresh (skip cached results)\n\n' +
'Example:\n' +
' ' + base + '/?referer=none&force=1&q=https://example.com/api\n'
);
return;
}
if (parsed.target.protocol !== 'https') {
res.writeHead(400, { 'content-type': 'text/plain' });
res.end('Only HTTPS targets are supported.');
return;
}
proxyRequest(req, res, parsed.target, 0, null, parsed.opts);
});
server.listen(port, host, function() {
console.log(
'-== CORS proxy listening on http://' +
(host === '0.0.0.0' || host === '127.0.0.1' ? 'localhost' : host) +
':' + port + ' (backend: ' + backend + ') ==-\n'
);
});
@jtbr
Copy link
Author

jtbr commented Feb 18, 2026

Was using corsproxy.io, but that became paid and none of the other free proxies seem to work. But this does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment