Finish nitro mitm proxy rewrite

This commit is contained in:
UnfamiliarLegacy 2024-10-24 05:07:11 +02:00
parent fe97142bfe
commit df53183034
6 changed files with 255 additions and 15 deletions

View File

@ -12,6 +12,7 @@
<javafx.version>1.8</javafx.version>
<jettyVersion>9.4.53.v20231009</jettyVersion>
<logback.version>1.3.12</logback.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
</properties>
<parent>
@ -331,19 +332,19 @@
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
<version>${bouncycastle.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk18on -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.78.1</version>
<version>${bouncycastle.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bctls-jdk18on -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bctls-jdk18on</artifactId>
<version>1.78.1</version>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>G-Earth</groupId>

View File

@ -121,7 +121,7 @@ public class NitroProxyProvider implements ProxyProvider, NitroHttpProxyServerCa
}
@Override
public String replaceWebsocketServer(String configUrl, String websocketUrl) {
public String replaceWebsocketServer(String websocketUrl) {
originalWebsocketUrl = websocketUrl;
return String.format("wss://127.0.0.1:%d", websocketPort);

View File

@ -2,19 +2,35 @@ package gearth.protocol.connection.proxy.nitro.http;
import com.github.monkeywie.proxyee.crt.CertUtil;
import com.github.monkeywie.proxyee.server.HttpProxyCACertFactory;
import com.github.monkeywie.proxyee.server.HttpProxyServerConfig;
import io.netty.buffer.ByteBufAllocator;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLEngine;
import java.io.File;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class NitroCertificateFactory implements HttpProxyCACertFactory {
@ -25,6 +41,7 @@ public class NitroCertificateFactory implements HttpProxyCACertFactory {
private X509Certificate caCert;
private PrivateKey caKey;
private HttpProxyServerConfig config;
public NitroCertificateFactory() {
this.caCertFile = new File(String.format("./%s.crt", NitroAuthority.CERT_ALIAS));
@ -107,8 +124,57 @@ public class NitroCertificateFactory implements HttpProxyCACertFactory {
return this.caKey;
}
public SSLEngine websocketSslEngine(String host) {
throw new UnsupportedOperationException("Not implemented");
public void setServerConfig(HttpProxyServerConfig config) {
this.config = config;
}
public SSLEngine websocketSslEngine(String commonName) {
if (this.config == null) {
throw new IllegalStateException("Server config not set");
}
try {
final X509Certificate cert = generateServerCert(commonName,
new GeneralName(GeneralName.dNSName, "localhost"),
new GeneralName(GeneralName.iPAddress, "127.0.0.1"));
final SslContext ctx = SslContextBuilder.forServer(this.config.getServerPriKey(), cert).build();
return ctx.newEngine(ByteBufAllocator.DEFAULT);
} catch (Exception e) {
log.error("Failed to create SSLEngine", e);
return null;
}
}
private X509Certificate generateServerCert(String commonName, GeneralName... san) throws Exception {
final String issuer = this.config.getIssuer();
final PrivateKey caPriKey = this.config.getCaPriKey();
final Date caNotBefore = this.config.getCaNotBefore();
final Date caNotAfter = this.config.getCaNotAfter();
final PublicKey serverPubKey = this.config.getServerPubKey();
// Replace "CN" in cert authority
final String subject = Stream.of(issuer.split(", ")).map(item -> {
String[] arr = item.split("=");
if ("CN".equals(arr[0])) {
return "CN=" + commonName;
} else {
return item;
}
}).collect(Collectors.joining(", "));
final JcaX509v3CertificateBuilder jv3Builder = new JcaX509v3CertificateBuilder(new X500Name(issuer),
BigInteger.valueOf(System.currentTimeMillis() + (long) (Math.random() * 10000) + 1000),
caNotBefore,
caNotAfter,
new X500Name(subject),
serverPubKey);
jv3Builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(san));
final ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(caPriKey);
return new JcaX509CertificateConverter().getCertificate(jv3Builder.build(signer));
}
}

View File

@ -133,6 +133,7 @@ public class NitroHttpProxy {
proxyServer.startAsync(NitroConstants.HTTP_PORT);
// Hack to swap the SSL context.
// Need to set this after proxyServer is started because starting it will override the configured SSL context.
try {
Security.addProvider(new BouncyCastleProvider());
@ -150,6 +151,9 @@ public class NitroHttpProxy {
return false;
}
// Add config to factory so websocket server can use it as well.
this.certificateFactory.setServerConfig(config);
if (!registerProxy()) {
proxyServer.close();

View File

@ -2,19 +2,59 @@ package gearth.protocol.connection.proxy.nitro.http;
import com.github.monkeywie.proxyee.intercept.HttpProxyInterceptInitializer;
import com.github.monkeywie.proxyee.intercept.HttpProxyInterceptPipeline;
import com.github.monkeywie.proxyee.intercept.common.FullRequestIntercept;
import com.github.monkeywie.proxyee.intercept.common.FullResponseIntercept;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import com.github.monkeywie.proxyee.util.ByteUtil;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.http.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class NitroHttpProxyIntercept extends HttpProxyInterceptInitializer {
private static final Logger log = LoggerFactory.getLogger(NitroHttpProxyIntercept.class);
public NitroHttpProxyIntercept(NitroHttpProxyServerCallback serverCallback) {
private static final String NitroConfigSearch = "socket.url";
private static final String NitroClientSearch = "configurationUrls:";
private static final Pattern NitroConfigPattern = Pattern.compile("[\"']socket\\.url[\"']:(\\s+)?[\"'](wss?:.*?)[\"']", Pattern.MULTILINE);
// https://developers.cloudflare.com/fundamentals/get-started/reference/cloudflare-cookies/
private static final HashSet<String> CloudflareCookies = new HashSet<>(Arrays.asList(
"__cflb",
"__cf_bm",
"__cfseq",
"cf_ob_info",
"cf_use_ob",
"__cfwaitingroom",
"__cfruid",
"_cfuvid",
"cf_clearance",
"cf_chl_rc_i",
"cf_chl_rc_ni",
"cf_chl_rc_m"
));
private static final String HeaderAcceptEncoding = "Accept-Encoding";
private static final String HeaderAge = "Age";
private static final String HeaderCacheControl = "Cache-Control";
private static final String HeaderContentSecurityPolicy = "Content-Security-Policy";
private static final String HeaderETag = "ETag";
private static final String HeaderIfNoneMatch = "If-None-Match";
private static final String HeaderIfModifiedSince = "If-Modified-Since";
private static final String HeaderLastModified = "Last-Modified";
private final NitroHttpProxyServerCallback callback;
public NitroHttpProxyIntercept(NitroHttpProxyServerCallback callback) {
this.callback = callback;
}
@Override
@ -22,14 +62,144 @@ public class NitroHttpProxyIntercept extends HttpProxyInterceptInitializer {
pipeline.addLast(new FullResponseIntercept() {
@Override
public boolean match(HttpRequest httpRequest, HttpResponse httpResponse, HttpProxyInterceptPipeline httpProxyInterceptPipeline) {
log.debug("Intercepting response for {}", httpRequest.uri());
return false;
log.debug("Intercepting response for {} {}", httpRequest.headers().get(HttpHeaderNames.HOST), httpRequest.uri());
return true;
}
@Override
public void handleResponse(HttpRequest httpRequest, FullHttpResponse httpResponse, HttpProxyInterceptPipeline pipeline) {
super.handleResponse(httpRequest, httpResponse, pipeline);
// Strip cache headers.
stripCacheHeaders(httpResponse.headers());
// Check for response body.
final ByteBuf content = httpResponse.content();
if (content == null || content.readableBytes() == 0) {
return;
}
// Find nitro configuration.
if (ByteUtil.findText(content, NitroConfigSearch) != -1) {
final String responseBody = responseRead(httpResponse);
final Matcher matcher = NitroConfigPattern.matcher(responseBody);
// Replace websocket with proxy.
if (matcher.find()) {
final String originalWebsocket = matcher.group(2).replace("\\/", "/");
final String replacementWebsocket = callback.replaceWebsocketServer(originalWebsocket);
if (replacementWebsocket != null) {
final String updatedBody = responseBody.replace(matcher.group(2), replacementWebsocket);
responseWrite(httpResponse, updatedBody);
}
}
// Retrieve cookies for request to the origin.
final String requestCookies = parseCookies(httpRequest);
if (requestCookies != null && !requestCookies.isEmpty()) {
callback.setOriginCookies(requestCookies);
}
}
// Strip CSP headers
if (ByteUtil.findText(content, NitroClientSearch) != -1) {
stripContentSecurityPolicy(httpResponse);
}
}
});
pipeline.addLast(new FullRequestIntercept() {
@Override
public boolean match(HttpRequest httpRequest, HttpProxyInterceptPipeline pipeline) {
log.debug("Intercepting request for {} {}", httpRequest.headers().get(HttpHeaderNames.HOST), httpRequest.uri());
return true;
}
@Override
public void handleRequest(FullHttpRequest httpRequest, HttpProxyInterceptPipeline pipeline) {
// Disable caching.
stripCacheHeaders(httpRequest.headers());
}
});
}
/**
* Check if cookies from the request need to be recorded for the websocket connection to the origin server.
*/
private static String parseCookies(final HttpRequest request) {
final List<String> result = new ArrayList<>();
final List<String> cookieHeaders = request.headers().getAll("Cookie");
for (final String cookieHeader : cookieHeaders) {
final String[] cookies = cookieHeader.split(";");
for (final String cookie : cookies) {
final String[] parts = cookie.trim().split("=");
if (CloudflareCookies.contains(parts[0])) {
result.add(cookie.trim());
}
}
}
if (result.isEmpty()) {
return null;
}
return String.join("; ", result);
}
/**
* Modify Content-Security-Policy header, which could prevent Nitro from connecting with G-Earth.
*/
private static void stripContentSecurityPolicy(FullHttpResponse response) {
final HttpHeaders headers = response.headers();
if (!headers.contains(HeaderContentSecurityPolicy)){
return;
}
String csp = headers.get(HeaderContentSecurityPolicy);
if (csp.contains("connect-src")) {
csp = csp.replace("connect-src", "connect-src *");
} else if (csp.contains("default-src")) {
csp = csp.replace("default-src", "default-src *");
}
headers.set(HeaderContentSecurityPolicy, csp);
}
/**
* Strip cache headers from the response.
*/
private static void stripCacheHeaders(HttpHeaders headers) {
headers.remove(HeaderAcceptEncoding);
headers.remove(HeaderAge);
headers.remove(HeaderCacheControl);
headers.remove(HeaderETag);
headers.remove(HeaderIfNoneMatch);
headers.remove(HeaderIfModifiedSince);
headers.remove(HeaderLastModified);
}
private static String responseRead(FullHttpResponse response) {
final ByteBuf contentBuf = response.content();
return contentBuf.toString(StandardCharsets.UTF_8);
}
private static void responseWrite(FullHttpResponse response, String content) {
final byte[] body = content.getBytes(StandardCharsets.UTF_8);
// Update content.
response.content().clear().writeBytes(body);
// Update content-length.
HttpUtil.setContentLength(response, body.length);
// Ensure modified response is not cached.
stripCacheHeaders(response.headers());
}
}

View File

@ -5,11 +5,10 @@ public interface NitroHttpProxyServerCallback {
/**
* Specify a replacement for the given websocket url.
*
* @param configUrl The url at which the websocket url was found.
* @param websocketUrl The hotel websocket url.
* @return Return null to not replace anything, otherwise specify an alternative websocket url.
*/
String replaceWebsocketServer(String configUrl, String websocketUrl);
String replaceWebsocketServer(String websocketUrl);
/**
* Sets the parsed cookies for the origin WebSocket connection.