This commit is contained in:
cheney 2026-06-30 23:00:35 +08:00
commit 80e72745f6
10 changed files with 694 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
certs/rootCA.*
certs/generated/
captures/
npm-debug.log*

131
README.md Normal file
View File

@ -0,0 +1,131 @@
# Local Node MITM Proxy
一个本地 HTTP/HTTPS 调试代理,行为类似 Fiddler 的核心代理能力:
- 代理普通 HTTP 请求并保存请求/响应明文。
- 处理 HTTPS `CONNECT`
- 对配置匹配的域名执行 HTTPS MITM 解密,例如 `*.sunyard.com`
- 对未匹配域名只做 TCP 隧道透传,不解密。
- 生成并安装自定义根证书。
- 一键启用/关闭 Windows 当前用户系统代理。
> 仅在你拥有授权的设备、账号、网络和域名上使用。安装根证书后,本机信任此代理签发的站点证书;请妥善保管 `certs/rootCA.key.pem`,使用完及时关闭代理并移除证书。
## 环境
- Node.js 18+
- Windows PowerShell
## 安装依赖
```powershell
npm install
```
## 配置
编辑 `config.json`
```json
{
"listenHost": "127.0.0.1",
"listenPort": 8888,
"interceptDomains": ["*.sunyard.com"],
"captureBodies": true,
"maxBodyBytes": 1048576,
"certDir": "certs",
"capturesDir": "captures"
}
```
`interceptDomains` 支持:
- 精确域名:`api.sunyard.com`
- 通配子域名:`*.sunyard.com`
- 全部解密:`*`,不建议日常使用
## 生成根证书
```powershell
npm run cert:generate
```
会生成:
- `certs/rootCA.key.pem`:根证书私钥,必须保密
- `certs/rootCA.cert.pem`PEM 根证书
- `certs/rootCA.cert.cer`Windows 可安装证书
如果需要重新生成,先删除 `certs/rootCA.*``certs/generated/`
## 安装根证书到 Windows 当前用户
```powershell
npm run cert:install:windows
```
这会导入到 `Cert:\CurrentUser\Root`。安装后请重启浏览器或目标应用。
卸载根证书:
```powershell
npm run cert:uninstall:windows
```
## 启动代理
```powershell
npm start
```
默认监听:`127.0.0.1:8888`。
## 设置系统代理
启用 Windows 当前用户系统代理:
```powershell
npm run proxy:enable:windows
```
关闭系统代理:
```powershell
npm run proxy:disable:windows
```
脚本修改的是 Windows Internet Settings通常 Chrome、Edge、系统组件和很多桌面应用会使用它。某些应用有自己的代理设置或证书信任库需要单独配置。
## 手动代理设置
如果不想修改系统代理,可以在浏览器或应用里手动配置:
- HTTP 代理:`127.0.0.1:8888`
- HTTPS 代理:`127.0.0.1:8888`
## 查看捕获数据
请求记录写入 `captures/`,每个请求一个 JSON 文件,包含:
- URL、方法、请求头
- 请求体,文本为 UTF-8二进制为 Base64
- 响应状态、响应头
- 响应体,自动尝试解 gzip/br/deflate
- `mitm: true` 表示 HTTPS 已解密拦截
## 验证示例
未配置进 `interceptDomains` 的 HTTPS 域名会透传:
```powershell
curl.exe --ssl-no-revoke -x http://127.0.0.1:8888 https://example.com/
```
如果要测试 HTTPS 解密,把测试域名加入 `interceptDomains` 后,确保系统/客户端信任 `rootCA.cert.cer`,再通过代理访问该域名。
## 重要限制
- 不支持 HTTP/2 到客户端侧MITM 后按 HTTP/1.1 转发。
- 证书固定、公钥固定、私有信任库、移动端 App 等场景可能拒绝 MITM。
- 系统代理不等于所有网络流量,非 HTTP/HTTPS 协议不会被该代理处理。
- 仅支持基础抓包保存,没有 GUI、断点修改、重放等 Fiddler 高级功能。

9
config.json Normal file
View File

@ -0,0 +1,9 @@
{
"listenHost": "127.0.0.1",
"listenPort": 8888,
"interceptDomains": ["*.sunyard.com"],
"captureBodies": true,
"maxBodyBytes": 1048576,
"certDir": "certs",
"capturesDir": "captures"
}

27
package-lock.json generated Normal file
View File

@ -0,0 +1,27 @@
{
"name": "local-mitm-proxy",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "local-mitm-proxy",
"version": "0.1.0",
"dependencies": {
"node-forge": "^1.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
}
}
}

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "local-mitm-proxy",
"version": "0.1.0",
"private": true,
"description": "Local HTTP/HTTPS debugging proxy with custom CA and domain-scoped MITM interception.",
"main": "src/proxy.js",
"type": "commonjs",
"scripts": {
"start": "node src/proxy.js",
"cert:generate": "node scripts/generate-ca.js",
"cert:install:windows": "powershell -ExecutionPolicy Bypass -File scripts/install-root-ca-windows.ps1",
"cert:uninstall:windows": "powershell -ExecutionPolicy Bypass -File scripts/uninstall-root-ca-windows.ps1",
"proxy:enable:windows": "powershell -ExecutionPolicy Bypass -File scripts/set-windows-proxy.ps1 -Enable",
"proxy:disable:windows": "powershell -ExecutionPolicy Bypass -File scripts/set-windows-proxy.ps1 -Disable"
},
"dependencies": {
"node-forge": "^1.3.1"
},
"engines": {
"node": ">=18"
}
}

52
scripts/generate-ca.js Normal file
View File

@ -0,0 +1,52 @@
const fs = require('fs');
const path = require('path');
const forge = require('node-forge');
const rootDir = path.resolve(__dirname, '..');
const rawConfigText = fs.readFileSync(path.join(rootDir, 'config.json'), 'utf8');
const configText = rawConfigText.charCodeAt(0) === 0xfeff ? rawConfigText.slice(1) : rawConfigText;
const config = JSON.parse(configText);
const certDir = path.resolve(rootDir, config.certDir || 'certs');
const keyPath = path.join(certDir, 'rootCA.key.pem');
const certPath = path.join(certDir, 'rootCA.cert.pem');
const certDerPath = path.join(certDir, 'rootCA.cert.cer');
fs.mkdirSync(certDir, { recursive: true });
if (fs.existsSync(keyPath) || fs.existsSync(certPath) || fs.existsSync(certDerPath)) {
console.error('Root CA already exists. Delete certs/rootCA.* first if you intentionally want to regenerate it.');
process.exit(1);
}
console.log('Generating 2048-bit RSA root CA...');
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = forge.util.bytesToHex(forge.random.getBytesSync(16)).replace(/^00/, '01');
cert.validity.notBefore = new Date(Date.now() - 60 * 1000);
cert.validity.notAfter = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
cert.setSubject([
{ name: 'commonName', value: 'Local Node MITM Proxy Root CA' },
{ name: 'organizationName', value: 'Local Development' },
{ shortName: 'OU', value: 'Debug Proxy' },
]);
cert.setIssuer(cert.subject.attributes);
cert.setExtensions([
{ name: 'basicConstraints', cA: true, critical: true },
{ name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
{ name: 'subjectKeyIdentifier' },
]);
cert.sign(keys.privateKey, forge.md.sha256.create());
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
const certPem = forge.pki.certificateToPem(cert);
const certDer = Buffer.from(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), 'binary');
fs.writeFileSync(keyPath, keyPem, 'utf8');
fs.writeFileSync(certPath, certPem, 'utf8');
fs.writeFileSync(certDerPath, certDer);
console.log(`Created private key: ${keyPath}`);
console.log(`Created PEM cert: ${certPath}`);
console.log(`Created DER cert: ${certDerPath}`);
console.log('Keep rootCA.key.pem private. Anyone with it can mint trusted certificates on this machine.');

View File

@ -0,0 +1,9 @@
param()
$ErrorActionPreference = 'Stop'
$certPath = Join-Path $PSScriptRoot '..\certs\rootCA.cert.cer'
$resolved = Resolve-Path $certPath
Write-Host "Installing root CA into CurrentUser\Root: $resolved"
Import-Certificate -FilePath $resolved -CertStoreLocation Cert:\CurrentUser\Root | Out-Null
Write-Host 'Installed. Restart browsers/apps that cache trust settings.'

View File

@ -0,0 +1,36 @@
param(
[switch]$Enable,
[switch]$Disable,
[string]$ProxyServer = '127.0.0.1:8888',
[string]$Override = '<local>'
)
$ErrorActionPreference = 'Stop'
$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings'
if ($Enable -eq $Disable) {
throw 'Pass exactly one of -Enable or -Disable.'
}
if ($Enable) {
Set-ItemProperty -Path $key -Name ProxyEnable -Type DWord -Value 1
Set-ItemProperty -Path $key -Name ProxyServer -Type String -Value $ProxyServer
Set-ItemProperty -Path $key -Name ProxyOverride -Type String -Value $Override
Write-Host "Enabled Windows proxy: $ProxyServer"
} else {
Set-ItemProperty -Path $key -Name ProxyEnable -Type DWord -Value 0
Write-Host 'Disabled Windows proxy.'
}
$source = @"
using System;
using System.Runtime.InteropServices;
public static class WinInetNative {
[DllImport("wininet.dll", SetLastError = true)]
public static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength);
}
"@
Add-Type -TypeDefinition $source -ErrorAction SilentlyContinue | Out-Null
[WinInetNative]::InternetSetOption([IntPtr]::Zero, 39, [IntPtr]::Zero, 0) | Out-Null
[WinInetNative]::InternetSetOption([IntPtr]::Zero, 37, [IntPtr]::Zero, 0) | Out-Null

View File

@ -0,0 +1,20 @@
param()
$ErrorActionPreference = 'Stop'
$subject = 'CN=Local Node MITM Proxy Root CA'
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
$store.Open('ReadWrite')
try {
$matches = @($store.Certificates | Where-Object { $_.Subject -eq $subject })
if ($matches.Count -eq 0) {
Write-Host "No matching certificate found: $subject"
exit 0
}
foreach ($cert in $matches) {
Write-Host "Removing $($cert.Subject) thumbprint=$($cert.Thumbprint)"
$store.Remove($cert)
}
Write-Host 'Removed matching root CA certificate(s).'
} finally {
$store.Close()
}

383
src/proxy.js Normal file
View File

@ -0,0 +1,383 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const zlib = require('zlib');
const forge = require('node-forge');
const rootDir = path.resolve(__dirname, '..');
const config = loadConfig();
const certDir = path.resolve(rootDir, config.certDir || 'certs');
const capturesDir = path.resolve(rootDir, config.capturesDir || 'captures');
const generatedCertDir = path.join(certDir, 'generated');
ensureDir(capturesDir);
ensureDir(generatedCertDir);
const ca = loadCa();
const secureContextCache = new Map();
const server = http.createServer(handleHttpRequest);
server.on('connect', handleConnect);
server.on('clientError', (err, socket) => {
console.error('[clientError]', err.message);
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.listen(config.listenPort, config.listenHost, () => {
console.log(`Proxy listening on ${config.listenHost}:${config.listenPort}`);
console.log(`Intercept domains: ${(config.interceptDomains || []).join(', ') || '(none)'}`);
console.log(`Captures: ${capturesDir}`);
});
function loadConfig() {
const configPath = path.resolve(rootDir, 'config.json');
if (!fs.existsSync(configPath)) throw new Error(`Missing config file: ${configPath}`);
return JSON.parse(stripBom(fs.readFileSync(configPath, 'utf8')));
}
function loadCa() {
const keyPath = path.join(certDir, 'rootCA.key.pem');
const certPath = path.join(certDir, 'rootCA.cert.pem');
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
throw new Error('Root CA not found. Run: npm run cert:generate');
}
const keyPem = fs.readFileSync(keyPath, 'utf8');
const certPem = fs.readFileSync(certPath, 'utf8');
return {
keyPem,
certPem,
key: forge.pki.privateKeyFromPem(keyPem),
cert: forge.pki.certificateFromPem(certPem),
};
}
function handleHttpRequest(clientReq, clientRes) {
const parsed = parseClientRequestUrl(clientReq);
if (!parsed) {
clientRes.writeHead(400);
clientRes.end('Bad proxy request URL');
return;
}
proxyRequest({
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port,
path: parsed.path,
clientReq,
clientRes,
mitm: false,
});
}
function handleConnect(req, clientSocket, head) {
const { hostname, port } = parseHostPort(req.url, 443);
if (!hostname) {
clientSocket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
return;
}
if (!shouldIntercept(hostname)) {
tunnelConnect(hostname, port, clientSocket, head);
return;
}
interceptConnect(hostname, port, clientSocket, head);
}
function tunnelConnect(hostname, port, clientSocket, head) {
const upstreamSocket = net.connect(port, hostname, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head && head.length) upstreamSocket.write(head);
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
upstreamSocket.on('error', (err) => {
console.error(`[tunnel:error] ${hostname}:${port} ${err.message}`);
if (clientSocket.writable) clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
});
}
function interceptConnect(hostname, port, clientSocket, head) {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
const tlsSocket = new tls.TLSSocket(clientSocket, {
isServer: true,
secureContext: getSecureContext(hostname),
});
if (head && head.length) tlsSocket.unshift(head);
tlsSocket.on('error', (err) => {
console.error(`[mitm:tls:error] ${hostname}:${port} ${err.message}`);
});
tlsSocket.once('secure', () => {
const mitmServer = http.createServer((clientReq, clientRes) => {
proxyRequest({
protocol: 'https:',
hostname,
port,
path: clientReq.url,
clientReq,
clientRes,
mitm: true,
});
});
mitmServer.emit('connection', tlsSocket);
});
}
function proxyRequest({ protocol, hostname, port, path: requestPath, clientReq, clientRes, mitm }) {
const isHttps = protocol === 'https:';
const headers = { ...clientReq.headers };
headers.host = formatHostHeader(hostname, port, isHttps ? 443 : 80);
delete headers['proxy-connection'];
delete headers['proxy-authorization'];
const requestStarted = new Date();
const requestChunks = [];
let requestBytes = 0;
clientReq.on('data', (chunk) => {
requestBytes += chunk.length;
if (config.captureBodies && requestBytes <= config.maxBodyBytes) requestChunks.push(chunk);
});
const upstreamReq = (isHttps ? https : http).request({
hostname,
port,
method: clientReq.method,
path: requestPath,
headers,
rejectUnauthorized: true,
}, (upstreamRes) => {
const responseHeaders = { ...upstreamRes.headers };
delete responseHeaders['proxy-authenticate'];
clientRes.writeHead(upstreamRes.statusCode || 502, responseHeaders);
const responseChunks = [];
let responseBytes = 0;
upstreamRes.on('data', (chunk) => {
responseBytes += chunk.length;
if (config.captureBodies && responseBytes <= config.maxBodyBytes) responseChunks.push(chunk);
});
upstreamRes.on('end', () => {
writeCapture({
startedAt: requestStarted,
protocol,
hostname,
port,
path: requestPath,
mitm,
request: {
method: clientReq.method,
headers: clientReq.headers,
body: Buffer.concat(requestChunks),
bodyTruncated: requestBytes > config.maxBodyBytes,
},
response: {
statusCode: upstreamRes.statusCode,
headers: upstreamRes.headers,
body: Buffer.concat(responseChunks),
bodyTruncated: responseBytes > config.maxBodyBytes,
},
});
});
upstreamRes.pipe(clientRes);
});
upstreamReq.on('error', (err) => {
console.error(`[proxy:error] ${clientReq.method} ${protocol}//${hostname}:${port}${requestPath} ${err.message}`);
if (!clientRes.headersSent) clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
clientRes.end(`Bad Gateway: ${err.message}`);
});
clientReq.pipe(upstreamReq);
}
function writeCapture(entry) {
try {
const safeHost = entry.hostname.replace(/[^a-zA-Z0-9.-]/g, '_');
const stamp = entry.startedAt.toISOString().replace(/[:.]/g, '-');
const fileName = `${stamp}_${safeHost}_${entry.request.method}.json`;
const filePath = path.join(capturesDir, fileName);
const requestBody = formatBody(entry.request.headers, entry.request.body);
const responseBody = formatBody(entry.response.headers, entry.response.body);
const data = {
startedAt: entry.startedAt.toISOString(),
mitm: entry.mitm,
url: `${entry.protocol}//${formatHostHeader(entry.hostname, entry.port, entry.protocol === 'https:' ? 443 : 80)}${entry.path}`,
request: {
method: entry.request.method,
headers: entry.request.headers,
bodyTruncated: entry.request.bodyTruncated,
body: requestBody,
},
response: {
statusCode: entry.response.statusCode,
headers: entry.response.headers,
bodyTruncated: entry.response.bodyTruncated,
body: responseBody,
},
};
fs.writeFile(filePath, JSON.stringify(data, null, 2), (err) => {
if (err) console.error(`[capture:error] ${err.message}`);
else console.log(`[capture] ${data.request.method} ${data.url} -> ${filePath}`);
});
} catch (err) {
console.error(`[capture:error] ${err.message}`);
}
}
function formatBody(headers, body) {
if (!body || body.length === 0) return null;
const decoded = decodeBody(headers, body);
const contentType = String(headers['content-type'] || headers['Content-Type'] || '');
const looksText = /^text\//i.test(contentType)
|| /json|xml|javascript|x-www-form-urlencoded/i.test(contentType)
|| isMostlyText(decoded);
if (looksText) return { encoding: 'utf8', content: decoded.toString('utf8') };
return { encoding: 'base64', content: decoded.toString('base64') };
}
function decodeBody(headers, body) {
const encoding = String(headers['content-encoding'] || headers['Content-Encoding'] || '').toLowerCase();
try {
if (encoding.includes('gzip')) return zlib.gunzipSync(body);
if (encoding.includes('br')) return zlib.brotliDecompressSync(body);
if (encoding.includes('deflate')) return zlib.inflateSync(body);
} catch (err) {
console.error(`[decodeBody:error] ${err.message}`);
}
return body;
}
function isMostlyText(buffer) {
if (!buffer.length) return true;
const sample = buffer.subarray(0, Math.min(buffer.length, 512));
let printable = 0;
for (const byte of sample) {
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126) || byte >= 128) printable += 1;
}
return printable / sample.length > 0.9;
}
function getSecureContext(hostname) {
const normalizedHost = hostname.toLowerCase();
if (!secureContextCache.has(normalizedHost)) {
const certPair = loadOrCreateLeafCertificate(normalizedHost);
secureContextCache.set(normalizedHost, tls.createSecureContext({
key: certPair.keyPem,
cert: certPair.certPem,
}));
}
return secureContextCache.get(normalizedHost);
}
function loadOrCreateLeafCertificate(hostname) {
const certPath = path.join(generatedCertDir, `${hostname}.cert.pem`);
const keyPath = path.join(generatedCertDir, `${hostname}.key.pem`);
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
return { certPem: fs.readFileSync(certPath, 'utf8'), keyPem: fs.readFileSync(keyPath, 'utf8') };
}
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = randomSerialNumber();
cert.validity.notBefore = new Date(Date.now() - 60 * 1000);
cert.validity.notAfter = new Date(Date.now() + 825 * 24 * 60 * 60 * 1000);
cert.setSubject([{ name: 'commonName', value: hostname }]);
cert.setIssuer(ca.cert.subject.attributes);
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', serverAuth: true },
{ name: 'subjectAltName', altNames: [{ type: 2, value: hostname }] },
]);
cert.sign(ca.key, forge.md.sha256.create());
const certPem = forge.pki.certificateToPem(cert);
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
fs.writeFileSync(certPath, certPem, 'utf8');
fs.writeFileSync(keyPath, keyPem, 'utf8');
return { certPem, keyPem };
}
function shouldIntercept(hostname) {
const lowerHost = hostname.toLowerCase();
return (config.interceptDomains || []).some((pattern) => matchDomainPattern(lowerHost, String(pattern).toLowerCase()));
}
function matchDomainPattern(hostname, pattern) {
if (pattern === '*' || pattern === hostname) return true;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1);
return hostname.endsWith(suffix) && hostname.length > suffix.length;
}
return false;
}
function parseClientRequestUrl(clientReq) {
try {
const parsed = new URL(clientReq.url);
return {
protocol: parsed.protocol,
hostname: parsed.hostname,
port: Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)),
path: `${parsed.pathname}${parsed.search}`,
};
} catch {
const hostHeader = clientReq.headers.host;
if (!hostHeader) return null;
const { hostname, port } = parseHostPort(hostHeader, 80);
return { protocol: 'http:', hostname, port, path: clientReq.url };
}
}
function parseHostPort(value, defaultPort) {
if (!value) return { hostname: null, port: defaultPort };
const text = String(value).trim();
if (text.startsWith('[')) {
const closing = text.indexOf(']');
const hostname = text.slice(1, closing);
const portText = text.slice(closing + 1).replace(/^:/, '');
return { hostname, port: Number(portText || defaultPort) };
}
const lastColon = text.lastIndexOf(':');
if (lastColon > -1 && text.indexOf(':') === lastColon) {
return { hostname: text.slice(0, lastColon), port: Number(text.slice(lastColon + 1) || defaultPort) };
}
return { hostname: text, port: defaultPort };
}
function formatHostHeader(hostname, port, defaultPort) {
return Number(port) === Number(defaultPort) ? hostname : `${hostname}:${port}`;
}
function stripBom(text) {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function randomSerialNumber() {
return forge.util.bytesToHex(forge.random.getBytesSync(16)).replace(/^00/, '01');
}