r2dbc-mysql icon indicating copy to clipboard operation
r2dbc-mysql copied to clipboard

[feature] Add allowMultiQueries Option to MySqlConnectionConfiguration

Open tjdskaqks opened this issue 1 year ago • 9 comments

Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

It is currently not possible to use the allowMultiQueries option in MySqlConnectionConfiguration.

Describe the solution you'd like Please add an option to specify allowMultiQueries in ConnectionFactoryOptions or MySqlConnectionConfiguration.

Additional context Add any other context or screenshots about the feature request here.

Code Example:

            val connectionFactory =
                MySqlConnectionFactory.from(
                    MySqlConnectionConfiguration.builder()
                        .host(url)
                        .username(username)
                        .password(password)
                        .database(dbname)
                        .sslMode(SslMode.DISABLED)
                        .port(port)
                        .allowMultiQueries(false) // <-
                        .build(),
                )

tjdskaqks avatar Nov 04 '24 12:11 tjdskaqks

Even now, multi queries are supported when using client-side prepared queries(default). Do you perhaps not want to allow multi queries? Could you please explain your use case in more detail?

jchrys avatar Nov 05 '24 16:11 jchrys

Yes, because I don't want to allow multiple queries. I want to prevent SQL injection vulnerabilities, so it would be nice to have an option.

tjdskaqks avatar Nov 06 '24 01:11 tjdskaqks

You're safe from SQL injection when you write static SQL and bind parameters using Statement#bind. However, in case you find any vulnerabilities, please report them via security. That said, you make a valid point—utilizing the allowMultiQueries flag can help reduce the attack surface. Thanks!

jchrys avatar Nov 06 '24 11:11 jchrys

Thank you.

tjdskaqks avatar Nov 07 '24 01:11 tjdskaqks

hello, @jchrys Please review the information below.

Author: brad Date: Mon Aug 25 18:54:33 2025 +0900

feat: add support for CLIENT_MULTI_STATEMENTS capability

diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Capability.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Capability.java index 26299a0..5471c9a 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Capability.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Capability.java

@@ -183,6 +183,38 @@ public final class Capability {
     public static final Capability DEFAULT = new Capability(ALL_SUPPORTED);
 
     private final long bitmap;
+    
+    /**
+     * Returns a new {@link Capability} with the given bit(s) enabled.
+     *
+     * @param flag the bit mask to enable.
+     * @return a new {@link Capability} with the bit(s) set.
+     */
+    public Capability enable(long flag) {
+        return of(this.bitmap | flag);
+    }
+
+    public Capability disable(long flag) {
+        return of(this.bitmap & ~flag);
+    }
+
+    /**
+     * Enables CLIENT_MULTI_STATEMENTS capability.
+     *
+     * @return a new {@link Capability} with MULTI_STATEMENTS enabled.
+     */
+    public Capability enableMultiStatements() {
+        return enable(MULTI_STATEMENTS);
+    }
+
+    /**
+     * Disables CLIENT_MULTI_STATEMENTS capability.
+     *
+     * @return a new {@link Capability} with MULTI_STATEMENTS disabled.
+     */
+    public Capability disableMultiStatements() {
+        return disable(MULTI_STATEMENTS);
+    }
 
     /**
      * Checks if the connection is using MariaDB capabilities.
@@ -429,6 +461,8 @@ public final class Capability {
         void disableConnectAttributes() {
             this.bitmap &= ~CONNECT_ATTRS;
         }
+        
+        void disableAllowMultiQueries() { this.bitmap &= ~MULTI_STATEMENTS; }
 
         Capability build() {
             return of(this.bitmap);
diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java
index a7c13c5..4b4c6c0 100644
--- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java
+++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java
@@ -126,10 +126,11 @@ final class InitFlow {
      * @param password              the password of the {@code user}.
      * @param compressionAlgorithms the list of compression algorithms.
      * @param zstdCompressionLevel  the zstd compression level.
+     * @param allowMultiQueries     whether to enable CLIENT_MULTI_STATEMENTS capability.
      * @return a {@link Mono} that indicates the initialization is done, or an error if the initialization failed.
      */
     static Mono<Void> initHandshake(Client client, SslMode sslMode, String database, String user,
-        @Nullable CharSequence password, Set<CompressionAlgorithm> compressionAlgorithms, int zstdCompressionLevel) {
+        @Nullable CharSequence password, Set<CompressionAlgorithm> compressionAlgorithms, int zstdCompressionLevel, boolean allowMultiQueries) {
         return client.exchange(new HandshakeExchangeable(
             client,
             sslMode,
@@ -137,7 +138,8 @@ final class InitFlow {
             user,
             password,
             compressionAlgorithms,
-            zstdCompressionLevel
+            zstdCompressionLevel,
+            allowMultiQueries
         )).then();
     }
 
@@ -503,6 +505,10 @@ final class HandshakeExchangeable extends FluxExchangeable<Void> {
 
     private final int zstdCompressionLevel;
 
+    private Capability loginCapability;
+
+    private final boolean allowMultiQueries;
+
     private boolean handshake = true;
 
     private MySqlAuthProvider authProvider;
@@ -513,7 +519,7 @@ final class HandshakeExchangeable extends FluxExchangeable<Void> {
 
     HandshakeExchangeable(Client client, SslMode sslMode, String database, String user,
         @Nullable CharSequence password, Set<CompressionAlgorithm> compressions,
-        int zstdCompressionLevel) {
+        int zstdCompressionLevel, boolean allowMultiQueries) {
         this.client = client;
         this.sslMode = sslMode;
         this.database = database;
@@ -521,6 +527,7 @@ final class HandshakeExchangeable extends FluxExchangeable<Void> {
         this.password = password;
         this.compressions = compressions;
         this.zstdCompressionLevel = zstdCompressionLevel;
+        this.allowMultiQueries = allowMultiQueries;
         this.sslCompleted = sslMode == SslMode.TUNNEL;
     }
 
@@ -543,6 +550,19 @@ final class HandshakeExchangeable extends FluxExchangeable<Void> {
                 HandshakeRequest request = (HandshakeRequest) message;
                 Capability capability = initHandshake(request);
 
+                // Toggle CLIENT_MULTI_STATEMENTS based on configuration
+                boolean serverSupportsMultiStatements = request.getServerCapability().isMultiStatementsAllowed();
+                if (!allowMultiQueries) {
+                    capability = capability.disableMultiStatements();
+                } else if (serverSupportsMultiStatements) {
+                    capability = capability.enableMultiStatements();
+                } else {
+                    capability = capability.disableMultiStatements(); // 서버 미지원: 강제 비활성
+                }
+
+                // Keep the adjusted capability for post-SSL handshake as well
+                this.loginCapability = capability;
+
                 if (capability.isSslEnabled()) {
                     emitNext(SslRequest.from(capability, client.getContext().getClientCollation().getId()), sink);
                 } else {
@@ -562,7 +582,8 @@ final class HandshakeExchangeable extends FluxExchangeable<Void> {
             sink.complete();
         } else if (message instanceof SyntheticSslResponseMessage) {
             sslCompleted = true;
-            emitNext(createHandshakeResponse(client.getContext().getCapability()), sink);
+            // Use the adjusted capability captured during the initial handshake
+            emitNext(createHandshakeResponse(this.loginCapability != null ? this.loginCapability : client.getContext().getCapability()), sink);
         } else if (message instanceof AuthMoreDataMessage) {
             AuthMoreDataMessage msg = (AuthMoreDataMessage) message;
 
diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java
index 39fb91e..837e627 100644
--- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java
+++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java
@@ -136,6 +136,8 @@ public final class MySqlConnectionConfiguration {
 
     private final boolean tinyInt1isBit;
 
+    private final boolean allowMultiQueries;
+
     private MySqlConnectionConfiguration(
             boolean isHost, String domain, int port, MySqlSslConfiguration ssl,
             boolean tcpKeepAlive, boolean tcpNoDelay, @Nullable Duration connectTimeout,
@@ -153,7 +155,8 @@ public final class MySqlConnectionConfiguration {
             Extensions extensions, @Nullable Publisher<String> passwordPublisher,
             @Nullable AddressResolverGroup<?> resolver,
             boolean metrics,
-            boolean tinyInt1isBit) {
+            boolean tinyInt1isBit,
+            boolean allowMultiQueries) {
         this.isHost = isHost;
         this.domain = domain;
         this.port = port;
@@ -185,6 +188,7 @@ public final class MySqlConnectionConfiguration {
         this.resolver = resolver;
         this.metrics = metrics;
         this.tinyInt1isBit = tinyInt1isBit;
+        this.allowMultiQueries = allowMultiQueries;
     }
 
     /**
@@ -327,6 +331,10 @@ public final class MySqlConnectionConfiguration {
     boolean isTinyInt1isBit() {
         return tinyInt1isBit;
     }
+    
+    boolean isAllowMultiQueries() {
+        return allowMultiQueries;
+    }
 
     @Override
     public boolean equals(Object o) {
@@ -367,7 +375,8 @@ public final class MySqlConnectionConfiguration {
             Objects.equals(passwordPublisher, that.passwordPublisher) &&
             Objects.equals(resolver, that.resolver) &&
             metrics == that.metrics &&
-            tinyInt1isBit == that.tinyInt1isBit;
+            tinyInt1isBit == that.tinyInt1isBit &&
+            allowMultiQueries == that.allowMultiQueries;
     }
 
     @Override
@@ -382,7 +391,7 @@ public final class MySqlConnectionConfiguration {
             loadLocalInfilePath, localInfileBufferSize,
             queryCacheSize, prepareCacheSize,
             compressionAlgorithms, zstdCompressionLevel,
-            loopResources, extensions, passwordPublisher, resolver, metrics, tinyInt1isBit);
+            loopResources, extensions, passwordPublisher, resolver, metrics, tinyInt1isBit, allowMultiQueries);
     }
 
     @Override
@@ -418,7 +427,8 @@ public final class MySqlConnectionConfiguration {
                 ", passwordPublisher=" + passwordPublisher +
                 ", resolver=" + resolver +
                 ", metrics=" + metrics +
-                ", tinyInt1isBit=" + tinyInt1isBit;
+                ", tinyInt1isBit=" + tinyInt1isBit +
+                ", allowMultiQueries=" + allowMultiQueries;
     }
 
     /**
@@ -521,6 +531,20 @@ public final class MySqlConnectionConfiguration {
         private boolean metrics;
 
         private boolean tinyInt1isBit = true;
+        
+        private boolean allowMultiQueries = true;
+        /**
+         * Option to enable multiple statements in a single query packet (CLIENT_MULTI_STATEMENTS).
+         * Defaults to {@code true} to preserve current behavior.
+         *
+         * @param allow {@code true} to allow multi-queries, {@code false} to disable.
+         * @return this {@link Builder}
+         * @since 1.4.0
+         */
+        public Builder allowMultiQueries(boolean allow) {
+            this.allowMultiQueries = allow;
+            return this;
+        }
 
         /**
          * Builds an immutable {@link MySqlConnectionConfiguration} with current options.
@@ -556,7 +580,8 @@ public final class MySqlConnectionConfiguration {
                 loadLocalInfilePath,
                 localInfileBufferSize, queryCacheSize, prepareCacheSize,
                 compressionAlgorithms, zstdCompressionLevel, loopResources,
-                Extensions.from(extensions, autodetectExtensions), passwordPublisher, resolver, metrics, tinyInt1isBit);
+                Extensions.from(extensions, autodetectExtensions), passwordPublisher, resolver, metrics, tinyInt1isBit,
+                allowMultiQueries);
         }
 
         /**
diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java
index 094674f..189bd35 100644
--- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java
+++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java
@@ -162,7 +162,8 @@ public final class MySqlConnectionFactory implements ConnectionFactory {
                 user,
                 password,
                 configuration.getCompressionAlgorithms(),
-                configuration.getZstdCompressionLevel()
+                configuration.getZstdCompressionLevel(),
+                configuration.isAllowMultiQueries()
             ).then(InitFlow.initSession(
                 client,
                 sessionDb,
diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java
index 5905c56..46b8a7f 100644
--- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java
+++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java
@@ -341,6 +341,14 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr
      */
     public static final Option<Boolean> TINY_INT_1_IS_BIT = Option.valueOf("tinyInt1isBit");
 
+    /**
+     * Option to enable multiple statements in a single query packet (CLIENT_MULTI_STATEMENTS).
+     * Defaults to {@code true}.
+     *
+     * @since 1.4.0
+     */
+    public static final Option<Boolean> ALLOW_MULTI_QUERIES = Option.valueOf("allowMultiQueries");
+
     @Override
     public ConnectionFactory create(ConnectionFactoryOptions options) {
         requireNonNull(options, "connectionFactoryOptions must not be null");
@@ -438,6 +446,8 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr
                 .to(builder::metrics);
         mapper.optional(TINY_INT_1_IS_BIT).asBoolean()
                 .to(builder::tinyInt1isBit);
+        mapper.optional(ALLOW_MULTI_QUERIES).asBoolean()
+                .to(builder::allowMultiQueries);
 
         return builder.build();
     }
diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java
index be48a22..0953635 100644
--- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java
+++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java
@@ -189,6 +189,7 @@ class MySqlConnectionFactoryProviderTest {
             .isExactlyInstanceOf(MyHostnameVerifier.class);
         assertThatExceptionOfType(MockException.class)
             .isThrownBy(() -> configuration.getSsl().customizeSslContext(SslContextBuilder.forClient()));
+        assertThat(configuration.isAllowMultiQueries()).isTrue();
     }
 
     @Test
@@ -332,6 +333,43 @@ class MySqlConnectionFactoryProviderTest {
         SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();
         assertThat(sslContextBuilder)
             .isSameAs(configuration.getSsl().customizeSslContext(sslContextBuilder));
+        assertThat(configuration.isAllowMultiQueries()).isTrue();
+    }
+
+    @Test
+    void urlAllowMultiQueries() {
+        MySqlConnectionConfiguration c1 = MySqlConnectionFactoryProvider.setup(
+            ConnectionFactoryOptions.parse("r2dbc:mysql://root@localhost:3306?allowMultiQueries=false"));
+        assertThat(c1.isAllowMultiQueries()).isFalse();
+
+        MySqlConnectionConfiguration c2 = MySqlConnectionFactoryProvider.setup(
+            ConnectionFactoryOptions.parse("r2dbc:mysql://root@localhost:3306?allowMultiQueries=true"));
+        assertThat(c2.isAllowMultiQueries()).isTrue();
+
+        MySqlConnectionConfiguration c3 = MySqlConnectionFactoryProvider.setup(
+            ConnectionFactoryOptions.parse("r2dbc:mysql://root@localhost:3306"));
+        assertThat(c3.isAllowMultiQueries()).isTrue(); // default
+    }
+
+    @Test
+    void programmaticAllowMultiQueries() {
+        MySqlConnectionConfiguration c1 = MySqlConnectionFactoryProvider.setup(
+            ConnectionFactoryOptions.builder()
+                .option(DRIVER, "mysql")
+                .option(HOST, "127.0.0.1")
+                .option(USER, "root")
+                .option(Option.valueOf("allowMultiQueries"), "false")
+                .build());
+        assertThat(c1.isAllowMultiQueries()).isFalse();
+
+        MySqlConnectionConfiguration c2 = MySqlConnectionFactoryProvider.setup(
+            ConnectionFactoryOptions.builder()
+                .option(DRIVER, "mysql")
+                .option(HOST, "127.0.0.1")
+                .option(USER, "root")
+                .option(Option.valueOf("allowMultiQueries"), true)
+                .build());
+        assertThat(c2.isAllowMultiQueries()).isTrue();
     }
 

tjdskaqks avatar Aug 25 '25 10:08 tjdskaqks

Could you open a PR with this change?

jchrys avatar Aug 26 '25 11:08 jchrys

@tjdskaqks

jchrys avatar Aug 26 '25 11:08 jchrys

Hello @jchrys , I reviewed the issue you opened. I attempted to submit a pull request, but I don’t have write access to asyncer-io/r2dbc-mysql. Could you please check the repository’s permissions and grant me access so I can submit the PR?

Image

tjdskaqks avatar Sep 29 '25 05:09 tjdskaqks

@tjdskaqks You can create a PR from your own fork. https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork

jchrys avatar Sep 29 '25 13:09 jchrys