雖然提到了中間人攻擊,但這不是一篇安全類文章,要經過中間人修改https內容,必須客戶端信任中間人提供的證書。javascript
我作這麼一個工做,最原始的需求,是爲了解決公司內網環境下 npm 包安裝的問題,簡單點講,就是切換倉庫和依賴鏡像源。經常使用的 cnpm 也提供鏡像功能,也能解決包依賴的硬編碼地址問題,可是不支持 lockfile, 也不支持 URLs as Dependencies 方式定義的package。最後決定採用代理的方式,用內網資源去響應外網請求。html
整個過程,真的充分感覺到了修改 https 請求的不易,畢竟 https 的誕生就是爲了防止內容盜取、篡改的。java
http請求的代理實現沒那麼多幺蛾子,就先略了...node
我在本地啓動了一個代理服務器,並注入了一些配置,將對 www.google.com.hk 的訪問重定向到了我在本機運行的一個 https 服務器。linux
這裏的演示,用的url替換的方式,這部分屬於具體的業務邏輯,後文的最終實現爲簡化版,雖然效果同樣~git
在實現代理服務以前,能夠簡單瞭解一下 https 服務的證書認證過程以及代理是怎麼工做的。github
一個正常 https 服務器的搭建時,咱們須要去證書機構申請一個域名證書,這裏機構必須是可信的。證書的信任過程是基於信任鏈的,若是電腦信任了CA, 也就信任了有CA證書籤發的域名證書。web
因此涉及兩個認證:npm
- 機構認證,對應的就是 CA 證書,在系統裏預置了
- 域名證書,用CA證書給域名證書籤名,獲得一個域名證書
你能夠本身生成一個CA證書,用來籤各類域名,也就是自簽名證書。自簽名證書是不能經過驗證的,你須要讓客戶端信任你的CA。api
http(s) 的 代理與普通請求有什麼區別?客戶端是如何告知代理目標服務器的地址的?我從別人文章裏截了個圖:
原文地址: Http 請求頭中的 Proxy-Connection/** 僅摘取部分核心代碼,沒法直接運行 **/
const https = require('https');
const fs = require('fs');
const forge = require('node-forge');
const net = require('net');
function connect(clientRequest, clientSocket, head) {
const protocol = clientRequest.connection?.encrypted ? 'https:' : 'http:';
const { port = 443, hostname } = url.parse(`${protocol}//${clientRequest.url}`);
// 鏈接目標服務器
const targetSocket = net.connect(port, targetUrl, () => {
// 通知客戶端已經創建鏈接
clientSocket.write(
'HTTP/1.1 200 Connection Established\r\n'
+ 'Proxy-agent: MITM-proxy\r\n'
+ '\r\n',
);
// 創建通訊隧道,轉發數據
targetSocket.write(head);
clientSocket.pipe(targetSocket).pipe(clientSocket);
});
}
// 建立域名證書, 啓動https服務做爲代理服務器
const serverCrt = createServerCertificate('localhost');
https.createServer({
key: forge.pki.privateKeyToPem(serverCrt.key),
cert: forge.pki.certificateToPem(serverCrt.cert),
})
.on('connection', console.log)
.on('connect', connect) // 創建通訊隧道
.listen(6666, () => {
console.log('代理服務器已啓動, 代理地址:https://localhost:6666');
});
複製代碼
上面的代碼實現,看起來可能沒什麼養分,不過能夠幫助理解代理模式下的證書認證過程。上面過程涉及兩次認證:
- 代理服務器是 https 服務,客戶端與代理服務器之間的鏈接須要認證
- 客戶端須要校驗目標服務器的證書,生成會話的加密數據,用於後續通訊
嗯,問題是,
- 這兩次認證分別發生在何時?
- 代碼裏面 connection、connect 事件分別在什麼狀況下觸發?
能夠嘗試將代理地址設置成 https://127.0.0.1:6666,(注意代理域名換了,代理服務的證書驗證會成問題) 而後你將看到 connection 事件被觸發,而後告訴你客戶端主動斷開了鏈接...而後...就沒有而後了。
若是代理服務器的證書認證經過,將會前後看到 connection、connect 事件被觸發。
至於客戶端須要校驗的目標服務器的證書,是在代理服務與目標服務器創建鏈接以後,經過 pipe 傳給客戶端的。
這個答案也簡單,上面兩個認證中的第二個,也就是客戶端對目標服務器的證書認證是無法經過的,因而鏈接被斷開。
那,若是咱們讓 「另外一個服務器」 響應正確的證書,或者說「僞造目標服務器」,是否就能正確創建鏈接,而後...隨心所欲了?
咱們在搭建一個 https 服務器的時候,一般須要申請一個域名證書,找一個客戶端信任的CA給你籤。
因此,其實讓證書可用條件還算簡單,用客戶端信任的CA證書籤一個域名證書,就能夠了。
在個人目標場景下,客戶端是由我本身控制的,因此,造一個 CA證書讓客戶端信任是可行的,既然CA都被信任了,那域名證書也就隨便籤了。
由於代理的目標地址不肯定,多是 a.com, 也多是 b.cn, 因此我指望造一個https服務,處理不一樣域名的請求。
這裏有一個叫 「SNI」的東西,也就是「服務器名稱指示」,用於實現服務與域名的一對多關係。
/** 僅摘取部分核心代碼,沒法直接運行 **/
/** 建立支持多域名的 https 服務 **/
function createFakeHttpsServer() {
return new https.Server({
SNICallback: (hostname, callback) => {
const { key, cert } = createServerCertificate(hostname);
callback(
null,
tls.createSecureContext({
key: forge.pki.privateKeyToPem(key),
cert: forge.pki.certificateToPem(cert),
}),
);
},
});
}
const fakeServer = createFakeHttpsServer();
/** 這裏是具體的業務,給客戶端返回想要提供的內容 **/
fakeServer.on('request', (req, res) => {
// do something
// 到這裏,證書部分已經經過了,正常響應請求就能夠
res.writeHead(200);
res.end('hello world\n');
}).listen(0);
複製代碼
綜合一下上面的步驟:
/** createServerCertificate 的實現,代碼比較長,先忽略了 **/
const https = require('https');
const fs = require('fs');
const forge = require('node-forge');
const net = require('net');
const tls = require('tls');
const url = require('url');
const createServerCertificate = require('./cert');
function connect(clientRequest, clientSocket, head) {
// 鏈接目標服務器
const targetSocket = net.connect(this.fakeServerPort, '127.0.0.1', () => {
// 通知客戶端已經創建鏈接
clientSocket.write(
'HTTP/1.1 200 Connection Established\r\n'
+ 'Proxy-agent: MITM-proxy\r\n'
+ '\r\n',
);
// 創建通訊隧道,轉發數據
targetSocket.write(head);
clientSocket.pipe(targetSocket).pipe(clientSocket);
});
}
/** 建立支持多域名的 https 服務 **/
function createFakeHttpsServer(fakeServerPort = 0) {
return new Promise((resolve, reject) => {
const fakeServer = new https.Server({
SNICallback: (hostname, callback) => {
const { key, cert } = createServerCertificate(hostname);
callback(
null,
tls.createSecureContext({
key: forge.pki.privateKeyToPem(key),
cert: forge.pki.certificateToPem(cert),
}),
);
},
})
fakeServer
.on('error', reject)
.listen(fakeServerPort, () => {
resolve(fakeServer);
});
});
}
function createProxyServer(proxyPort) {
return new Promise((resolve, reject) => {
const serverCrt = createServerCertificate('localhost');
const proxyServer = https.createServer({
key: forge.pki.privateKeyToPem(serverCrt.key),
cert: forge.pki.certificateToPem(serverCrt.cert),
})
.on('error', reject)
.listen(proxyPort, () => {
const proxyUrl = `https://localhost:${proxyPort}`;
console.log('啓動代理成功,代理地址:', proxyUrl);
resolve(proxyServer);
});
});
}
// 業務邏輯
function requestHandle(req, res) {
res.writeHead(200);
res.end('hello world\n');
}
// 這裏就是入口了
function main(proxyPort) {
return Promise.all([
createProxyServer(proxyPort),
createFakeHttpsServer(), //隨機端口
]).then(([proxyServer, fakeServer]) => {
// 創建客戶端到僞服務端的通訊隧道
proxyServer.on('connect', connect.bind({
fakeServerPort: fakeServer.address().port,
}));
// 僞服務端處理,能夠響應自定義內容
fakeServer.on('request', requestHandle);
}).then(() => {
console.log('everything is ok');
});
}
// 監聽異常,避免意外退出
process.on('uncaughtException', (err) => {
console.error(err);
});
main(6666);
複製代碼
cd demo/proxy
npm i
npm run test
複製代碼
不用了記得刪除證書~~