An overview guide to building a TLS client in Rust that can simulate Chrome's SSL handshake. Plus nodejs module and proxy support in the repo.
The source code is available here — github.com/gssvv/rust-boring-ssl-client
Tools used
- Rust (JavaScript cannot handle such low-level operations)
- BoringSSL bindings for the Rust (Chromium uses BoringSSL)
- H2 (HTTP/2 client)
- Neon (to create native Node.js modules)
What's the result?
Let's take an opensea.io GraphQL for example, which uses Cloudflare WAF.
Regular axios
request won't work:
const config = {
uri: "https://opensea.io/__api/graphql/",
host: "opensea.io",
method: "POST",
headers: [
["authority", "opensea.io"],
["content-type", "application/json"],
// ...
],
body: `{
\"id\": \"NavbarQuery\",
\"query\": \"query NavbarQuery(\\n $identity: AddressScalar!\\n) {\\n getAccount(address: $identity) {\\n imageUrl\\n id\\n }\\n}\\n\",
\"variables\": {
\"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
}
}`,
};
const axios = require("axios");
axios({
...config,
url: config.uri,
headers: Object.fromEntries(config.headers),
data: config.body,
validateStatus: () => true,
}).then((e) => console.log({ status: e.status, body: e.data }));
// {
// status: 403,
// body: '<!DOCTYPE html>\n' +
// '<html lang="en-US">\n' +
// ' <head>\n' +
// ' <title>Access denied</title>\n' +
// ' <meta http-equiv="X-UA-Compatible" content="IE=Edge" />\n' +
// ...
Let's try our module:
const { request } = require("./build/macos.node");
request(config, "", "").then(console.log);
// {
// status: 200,
// bodyJson: '{"data":{"getAccount":{"imageUrl":"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format","id":"QWNjb3VudFR5cGU6ODY3MDYyNDQw"}}}'
// }
You can also use it in Rust:
mod lib;
use tokio;
#[tokio::main]
async fn main() {
let body = String::from(
"{
\"id\": \"NavbarQuery\",
\"query\": \"query NavbarQuery(\\n $identity: AddressScalar!\\n) {\\n getAccount(address: $identity) {\\n imageUrl\\n id\\n }\\n}\\n\",
\"variables\": {
\"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
}
}",
);
let config = lib::RequestConfig {
body,
method: "POST".to_string(),
host: "opensea.io".to_string(),
uri: "https://opensea.io/__api/graphql/".to_string(),
headers: vec![
vec!["authority".to_string(), "opensea.io".to_string()],
vec!["content-type".to_string(),"application/json".to_string()],
vec!["origin".to_string(),
// ...
};
let res = lib::request(config).await.unwrap();
println!("res: {:?}", res);
// res: (200, "{\"data\":{\"getAccount\":{\"imageUrl\":\"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format\",\"id\":\"QWNjb3VudFR5cGU6ODY3MDYyNDQw\"}}}")
}
Let's build
I will cover the key points of the TLS client implementation. I will not describe the Neon interface building, Tokio runtime setup and other secondary aspects in detail.
So, here're the dependencies we need:
use h2::client;
use http::Request;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use boring::ssl::{ConnectConfiguration, SslConnector, SslMethod};
use once_cell::sync::OnceCell;
use std::error::Error;
use std::net::ToSocketAddrs;
use bytes::{BufMut, Bytes, BytesMut};
use neon::context::{Context, FunctionContext, ModuleContext};
use neon::prelude::*;
use tokio::runtime::Runtime;
Now we can start writing the main request function that opens a TCP connection:
pub struct RequestConfig {
pub method: String,
pub body: String,
pub host: String,
pub uri: String,
pub headers: Vec<Vec<String>>,
}
pub async fn request(request_config: RequestConfig) -> Result<(u16, String), Box<dyn Error>> {
let addr = format!("{}:443", request_config.host)
.to_socket_addrs()
.unwrap()
.next()
.unwrap();
let tcp = TcpStream::connect(&addr).await?;
connect_and_send_request(tcp, request_config).await
}
We'll get to connect_and_send_request
later.
The next step is to create SSL configuration. This is where we specify ciphers and TLS extensions settings.
pub fn get_connect_config() -> ConnectConfiguration {
let cipher_list = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA";
let mut builder = SslConnector::builder(SslMethod::tls()).unwrap();
builder.set_verify(boring::ssl::SslVerifyMode::NONE);
builder.set_grease_enabled(true);
builder.enable_ocsp_stapling();
builder.set_cipher_list(&cipher_list).unwrap();
builder
.set_alpn_protos(&[2, 104, 50, 8, 104, 116, 116, 112, 47, 49, 46, 49])
.unwrap();
builder.enable_signed_cert_timestamps();
let connector = builder.build();
let mut connect_config = connector.configure().unwrap();
connect_config.set_verify_hostname(false);
connect_config
}
Now we can use that config to perform a SSL handshake and initialize a client:
async fn connect_and_send_request(
tcp: TcpStream,
request_config: RequestConfig,
) -> Result<(u16, String), Box<dyn Error>> {
let connect_config = get_connect_config();
let res = tokio_boring::connect(connect_config, request_config.host.as_str(), tcp).await;
let tls = res.unwrap();
let (mut client, h2) = client::Builder::new()
.initial_connection_window_size(1024 * 1024 * 1024)
.initial_window_size(1024 * 1024 * 1024)
.handshake::<_, Bytes>(tls)
.await
.unwrap();
// ...
Now we can create our request and send it:
// ...
let mut request = Request::builder()
.version(http::version::Version::HTTP_2)
.method(request_config.method.as_str())
.uri(request_config.uri);
let mut i = 0;
while i < request_config.headers.len() {
request = request.header(
request_config.headers[i][0].as_str(),
request_config.headers[i][1].as_str(),
);
i = i + 1;
}
let has_body = request_config.body.len() > 0;
if has_body {
request = request.header("content-length", request_config.body.len());
}
let request = request.body(()).unwrap();
let (response, mut send_stream) = client.send_request(request, !has_body).unwrap();
if has_body {
send_stream
.send_data(Bytes::from(request_config.body), true)
.unwrap();
}
// ...
When the request is sent, we can get and return its result:
// ...
tokio::spawn(async move {
if let Err(e) = h2.await {
println!("GOT ERR={:?}", e);
}
});
let (res_parts, mut body) = response.await?.into_parts();
let mut response_buf = BytesMut::new();
while let Some(chunk) = body.data().await {
response_buf.put(chunk?);
}
Ok((
res_parts.status.as_u16(),
String::from_utf8(response_buf.to_vec())?,
))
}
Now we have a working TLS client!
Proxy implementation
To make it work with proxy, we have to send extra headers before the SSL handshake. Everything else is the same:
pub async fn request_with_proxy(
request_config: RequestConfig,
proxy_addr: String,
proxy_auth_in_base64: String,
) -> Result<(u16, String), Box<dyn Error>> {
let addr = proxy_addr.to_socket_addrs().unwrap().next().unwrap();
let mut tcp = TcpStream::connect(&addr).await?;
let connect_request = [
format!("CONNECT {}:443 HTTP/1.1", request_config.host).to_string(),
format!("Host: {}:443", request_config.host).to_string(),
format!("Proxy-Authorization: Basic {}", proxy_auth_in_base64),
"User-Agent: curl/7.81.0".to_string(),
"Connection: keep-alive".to_string(),
"\r\n".to_string(),
]
.join("\r\n");
tcp.write_all(connect_request.as_bytes()).await.unwrap();
let mut msg = vec![0; 1024];
loop {
tcp.readable().await?;
match tcp.try_read(&mut msg) {
Ok(n) => {
msg.truncate(n);
break;
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
continue;
}
Err(e) => {
return Err(e.into());
}
}
}
connect_and_send_request(tcp, request_config).await
}
That's it!
I couldn't find a solution like that on the internet and collected information bit by bit.
I really hope this quickly-made article will save someone's time just like it would save mine.
P.S. I am not a Rust developer, so be careful using this code in production.
Top comments (0)