fix(navicat): allow backup transactions through HTTP tunnel

This commit is contained in:
303562915 committed 2026-10-21 17:49:48 +08:00
1 parent b9690840ad
commit ed147aced7
2 files changed
+6 -118

No files matched your search

@@ -1,7 +1,5 @@
use base64::Engine;
use reqwest::{Client, Url};
use sqlparser::dialect::MySqlDialect;
use sqlparser::tokenizer::{Token, Tokenizer, Whitespace};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
@@ -240,10 +238,6 @@ async fn serve_client(stream: &mut TcpStream, endpoint: Arc<NavicatMysqlEndpoint
}
0x03 => {
let sql = String::from_utf8_lossy(payload).to_string();
if let Err(error) = validate_stateless_query(&sql) {
send_mysql_error(stream, sequence.wrapping_add(1), 1235, &error).await?;
continue;
}
if let Some(next_database) = parse_use_database(&sql) {
database = next_database;
write_packet(stream, sequence.wrapping_add(1), &mysql_ok_packet(0, 0, "")).await?;
@@ -271,51 +265,6 @@ async fn serve_client(stream: &mut TcpStream, endpoint: Arc<NavicatMysqlEndpoint
}
}
fn validate_stateless_query(sql: &str) -> Result<(), String> {
let mut tokens = Vec::new();
stateless_query_tokens(sql, &mut tokens)?;
for statement in tokens.split(|token| *token == Token::SemiColon) {
let word_is = |token: Option<&Token>, expected: &str| matches!(token, Some(Token::Word(word)) if word.value.eq_ignore_ascii_case(expected));
let first = statement.first();
let transaction =
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "XA"].iter().any(|keyword| word_is(first, keyword))
|| (word_is(first, "START") && word_is(statement.get(1), "TRANSACTION"))
|| (word_is(first, "RELEASE") && word_is(statement.get(1), "SAVEPOINT"))
|| (word_is(first, "SET")
&& statement.iter().any(|token| {
word_is(Some(token), "AUTOCOMMIT")
|| word_is(Some(token), "@@AUTOCOMMIT")
|| word_is(Some(token), "TRANSACTION")
}));
if transaction {
return Err(
"Transactions and autocommit settings are not supported by Navicat HTTP tunnels: each request uses a separate MySQL session"
.to_string(),
);
}
}
Ok(())
}
fn stateless_query_tokens(sql: &str, output: &mut Vec<Token>) -> Result<(), String> {
for token in Tokenizer::new(&MySqlDialect {}, sql)
.tokenize()
.map_err(|error| format!("Cannot validate Navicat HTTP tunnel query: {error}"))?
{
match token {
Token::Whitespace(Whitespace::MultiLineComment(comment)) => {
// MySQL executes versioned comments; inspect their SQL as well.
if let Some(body) = comment.strip_prefix('!').or_else(|| comment.strip_prefix("M!")) {
stateless_query_tokens(body.trim_start_matches(|ch: char| ch.is_ascii_digit()), output)?;
}
}
Token::Whitespace(_) | Token::EOF => {}
token => output.push(token),
}
}
Ok(())
}
fn mysql_handshake_greeting(auth_seed: &[u8; 20]) -> Vec<u8> {
let mut greeting = vec![10];
greeting.extend_from_slice(b"8.0.0-dbx-navicat\0");
@@ -577,8 +526,8 @@ fn push_lenenc_bytes(output: &mut Vec<u8>, value: &[u8]) {
#[cfg(test)]
mod tests {
use super::{
parse_navicat_response, parse_use_database, push_lenenc_bytes, read_packet, validate_stateless_query,
write_packet, NavicatMysqlEndpoint, CLIENT_PROTOCOL_41, CLIENT_SECURE_CONNECTION,
parse_navicat_response, parse_use_database, push_lenenc_bytes, read_packet, write_packet,
NavicatMysqlEndpoint, CLIENT_PROTOCOL_41, CLIENT_SECURE_CONNECTION,
};
use tokio::net::{TcpListener, TcpStream};
@@ -629,68 +578,6 @@ mod tests {
assert_eq!(parse_use_database("SELECT 1"), None);
}
#[test]
fn rejects_transaction_control_including_comments_and_autocommit() {
for sql in [
"BEGIN",
"START /* comment */ TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY",
"COMMIT",
"ROLLBACK TO SAVEPOINT s",
"SAVEPOINT s",
"RELEASE SAVEPOINT s",
"XA START 'x'",
"SET autocommit = 0",
"SET @@autocommit = 0",
"SET @@`autocommit` = 0",
"SET @@session.autocommit = OFF",
"SET SESSION `autocommit` = 0",
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ",
"/*!40101 SET AUTOCOMMIT=0 */",
"/*M! SET autocommit=0 */",
"START /*!40100 TRANSACTION */",
"SELECT 1; BEGIN",
] {
assert!(validate_stateless_query(sql).is_err(), "{sql}");
}
for sql in [
"SELECT 'BEGIN; SET autocommit=0'",
"SELECT `autocommit` FROM t",
"/* BEGIN */ SELECT 1",
"SET NAMES utf8mb4",
"SET SESSION group_concat_max_len = 1048576",
"SET @note = 'autocommit'",
"USE information_schema",
] {
assert!(validate_stateless_query(sql).is_ok(), "{sql}");
}
}
#[tokio::test]
async fn transaction_commands_fail_before_any_http_request() {
let http_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/ntunnel_mysql.php", http_listener.local_addr().unwrap());
let endpoint = NavicatMysqlEndpoint::new(&url, "localhost", 3306, "user", "password", None, 1).unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).await.unwrap();
let server = tokio::spawn(super::serve(listener, endpoint));
read_packet(&mut client).await.unwrap();
let mut handshake = [0; 32];
handshake[..4].copy_from_slice(&CLIENT_PROTOCOL_41.to_le_bytes());
write_packet(&mut client, 1, &handshake).await.unwrap();
assert_eq!(read_packet(&mut client).await.unwrap().1[0], 0);
for sql in ["START TRANSACTION", "BEGIN", "ROLLBACK", "COMMIT", "SET autocommit=0", "SET @@autocommit=0"] {
let response = send_test_command(&mut client, 0x03, sql.as_bytes()).await;
assert_eq!(response[0], 0xff, "{sql}");
assert_eq!(u16::from_le_bytes(response[1..3].try_into().unwrap()), 1235, "{sql}");
assert!(String::from_utf8_lossy(&response[9..]).contains("separate MySQL session"));
}
assert!(tokio::time::timeout(std::time::Duration::from_millis(50), http_listener.accept()).await.is_err());
assert_eq!(send_test_command(&mut client, 0x0e, b"").await[0], 0);
server.abort();
}
#[tokio::test]
async fn live_navicat_http_tunnel_query_use_ping_and_error_paths() {
let (Some(url), Some(username), Some(password)) = (
+4 -3
View File
@@ -23,6 +23,7 @@ can change a POST request into a GET.
This protocol supports regular queries, `USE`, ping, and disconnect. MySQL TLS
and prepared statements are not supported by the Navicat HTTP tunnel protocol.
Each HTTP request opens a separate MySQL session. Transactions (including manual
transactions and consistent backup snapshots), savepoints, and autocommit
settings are rejected before any SQL is sent to the PHP host.
Each HTTP request opens a separate MySQL session, so transaction state,
savepoints, and autocommit settings do not persist between requests. Backups
that rely on a consistent transaction snapshot are therefore not guaranteed to
represent one point in time when using this tunnel.