Skip to content

Commit 4882e07

Browse files
authored
[ZEPPELIN-6204] do not allow init params in JDBC URLs for H2
### What is this PR for? ZEPPELIN-6204 Slight tidy of the existing disallow list for strings in JDBC urls so that they are checked against just the query params and not the hostname in the URL. ### What type of PR is it? Bug Fix ### Todos * [ ] - Task ### What is the Jira issue? * Open an issue on Jira https://issues.apache.org/jira/browse/ZEPPELIN/ * Put link here, and add [ZEPPELIN-*Jira number*] in PR title, eg. [ZEPPELIN-533] ### How should this be tested? * Strongly recommended: add automated unit tests for any new or changed behavior * Outline any manual steps to test the PR here. ### Screenshots (if appropriate) ### Questions: * Does the license files need to update? no * Is there breaking changes for older versions? no * Does this needs documentation? no Closes #4949 from pjfanning/ZEPPELIN-6204-jdbc. Signed-off-by: ChanHo Lee <chanholee@apache.org>
1 parent bf62a2a commit 4882e07

3 files changed

Lines changed: 203 additions & 26 deletions

File tree

jdbc/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<commons.dbcp2.version>2.0.1</commons.dbcp2.version>
4040
<hive3.version>3.1.3</hive3.version>
4141
<kyuubi.version>1.9.1</kyuubi.version>
42+
<mysql.connector.version>9.3.0</mysql.connector.version>
4243

4344
<!--test library versions-->
4445
<mockrunner.jdbc.version>1.0.8</mockrunner.jdbc.version>
@@ -59,6 +60,13 @@
5960
<scope>test</scope>
6061
</dependency>
6162

63+
<dependency>
64+
<groupId>com.mysql</groupId>
65+
<artifactId>mysql-connector-j</artifactId>
66+
<version>${mysql.connector.version}</version>
67+
<scope>test</scope>
68+
</dependency>
69+
6270
<dependency>
6371
<groupId>org.apache.commons</groupId>
6472
<artifactId>commons-lang3</artifactId>

jdbc/src/main/java/org/apache/zeppelin/jdbc/JDBCInterpreter.java

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,22 @@ public class JDBCInterpreter extends KerberosInterpreter {
156156
"KerberosConfigPath", "KerberosKeytabPath", "KerberosCredentialCachePath",
157157
"extraCredentials", "roles", "sessionProperties"));
158158

159+
private static final String ALLOW_LOAD_LOCAL = "allowLoadLocal";
160+
159161
private static final String ALLOW_LOAD_LOCAL_IN_FILE_NAME = "allowLoadLocalInfile";
160162

161-
private static final String AUTO_DESERIALIZE = "autoDeserialize";
163+
private static final String ALLOW_LOAD_LOCAL_IN_FILE_IN_PATH = "allowLoadLocalInfileInPath";
162164

163165
private static final String ALLOW_LOCAL_IN_FILE_NAME = "allowLocalInfile";
164166

165167
private static final String ALLOW_URL_IN_LOCAL_IN_FILE_NAME = "allowUrlInLocalInfile";
166168

169+
private static final String AUTO_DESERIALIZE = "autoDeserialize";
170+
171+
private static final String SOCKET_FACTORY = "socketFactory";
172+
173+
private static final String INIT = "INIT";
174+
167175
// database --> Properties
168176
private final HashMap<String, Properties> basePropertiesMap;
169177
// username --> User Configuration
@@ -588,18 +596,127 @@ public Connection getConnection(InterpreterContext context)
588596
return connection;
589597
}
590598

591-
private void validateConnectionUrl(String url) {
592-
String decodedUrl;
593-
decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8);
599+
// package private for testing purposes
600+
static void validateConnectionUrl(String url) {
601+
final String decodedUrl = urlDecode(url, url, 0);
602+
final Map<String, String> params = parseUrlParameters(decodedUrl);
603+
604+
if (containsKeyIgnoreCase(params, ALLOW_LOAD_LOCAL) ||
605+
containsKeyIgnoreCase(params, ALLOW_LOAD_LOCAL_IN_FILE_NAME) ||
606+
containsKeyIgnoreCase(params, ALLOW_LOCAL_IN_FILE_NAME) ||
607+
containsKeyIgnoreCase(params, ALLOW_URL_IN_LOCAL_IN_FILE_NAME) ||
608+
containsKeyIgnoreCase(params, ALLOW_LOAD_LOCAL_IN_FILE_IN_PATH) ||
609+
containsKeyIgnoreCase(params, AUTO_DESERIALIZE) ||
610+
containsKeyIgnoreCase(params, SOCKET_FACTORY)) {
611+
throw new IllegalArgumentException("Connection URL contains sensitive configuration");
612+
}
594613

595-
if (containsIgnoreCase(decodedUrl, ALLOW_LOAD_LOCAL_IN_FILE_NAME) ||
596-
containsIgnoreCase(decodedUrl, AUTO_DESERIALIZE) ||
597-
containsIgnoreCase(decodedUrl, ALLOW_LOCAL_IN_FILE_NAME) ||
598-
containsIgnoreCase(decodedUrl, ALLOW_URL_IN_LOCAL_IN_FILE_NAME)) {
614+
// the INIT parameter name is a bit generic so we check it only for H2
615+
if (containsIgnoreCase(decodedUrl, "jdbc:h2") && containsKeyIgnoreCase(params, INIT)) {
599616
throw new IllegalArgumentException("Connection URL contains sensitive configuration");
600617
}
601618
}
602619

620+
/**
621+
* Decode the URL encoded string recursively until no more decoding is needed.
622+
* This is to handle cases where the URL might be double-encoded.
623+
*
624+
* @param url the original URL (for logging purposes)
625+
* @param encoded the URL encoded string
626+
* @param recurseCount the current recursion depth
627+
* @return the decoded string
628+
* @throws IllegalArgumentException if the recursion depth exceeds 10
629+
*/
630+
private static String urlDecode(final String url,
631+
final String encoded,
632+
final int recurseCount) {
633+
if (recurseCount > 10) {
634+
throw new IllegalArgumentException("illegal URL encoding detected: " + url);
635+
}
636+
final String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
637+
if (decoded.equals(encoded)) {
638+
return decoded; // No more decoding needed or max recursion reached
639+
}
640+
return urlDecode(url, decoded, recurseCount + 1);
641+
}
642+
643+
private static Map<String, String> parseUrlParameters(final String url) {
644+
final Map<String, String> parameters = new HashMap<>();
645+
646+
// MySQL supports parentheses in the URL
647+
// https://dev.mysql.com/doc/connectors/en/connector-j-reference-jdbc-url-format.html
648+
// eg jdbc:mysql://(host=myhost,port=1111,allowLoadLocalInfile=true)/db
649+
int parensIndex = extractFromParens(url, 0, parameters);
650+
while (parensIndex > 0) {
651+
parensIndex = extractFromParens(url, parensIndex, parameters);
652+
}
653+
654+
// Split the URL into the base part and the parameters part
655+
String[] parts = url.split("[?&;]");
656+
if (parts.length > 1) {
657+
// The first part is the base URL, so we start from the second part
658+
for (int i = 1; i < parts.length; i++) {
659+
splitNameValue(parts[i], parameters, true);
660+
}
661+
}
662+
return parameters;
663+
}
664+
665+
private static boolean containsKeyIgnoreCase(Map<String, String> map, String key) {
666+
for (String k : map.keySet()) {
667+
if (k.equalsIgnoreCase(key)) {
668+
return true;
669+
}
670+
}
671+
return false;
672+
}
673+
674+
/**
675+
* Extracts key-value pairs from parentheses in the input string.
676+
* The expected format is "(key1=value1, key2=value2, ...)".
677+
*
678+
* @param input the input string containing parameters in parentheses
679+
* @param initIndex the index to start searching for parentheses
680+
* @param parameters the map to store extracted key-value pairs
681+
* @return the index of the closing parenthesis or -1 if not found
682+
*/
683+
private static int extractFromParens(final String input,
684+
final int initIndex,
685+
final Map<String, String> parameters) {
686+
final int startIndex = input.indexOf('(', initIndex);
687+
if (startIndex == -1) {
688+
return -1;
689+
}
690+
final int endIndex = input.indexOf(')', startIndex);
691+
if (startIndex != -1 && endIndex != -1) {
692+
String params = input.substring(startIndex + 1, endIndex);
693+
String[] keyValuePairs = params.split(",");
694+
for (String pair : keyValuePairs) {
695+
splitNameValue(pair, parameters, false);
696+
}
697+
}
698+
return endIndex;
699+
}
700+
701+
/**
702+
* Splits a name-value pair and adds it to the parameters map.
703+
* Handles cases where the value might be missing.
704+
*
705+
* @param nameValue the name-value pair as a string
706+
* @param parameters the map to store the extracted key-value pair
707+
* @param allowEmptyValue whether to allow empty values
708+
*/
709+
private static void splitNameValue(String nameValue, Map<String, String> parameters,
710+
boolean allowEmptyValue) {
711+
String[] keyValue = nameValue.split("=");
712+
if (keyValue.length >= 2) {
713+
parameters.put(keyValue[0].trim(), keyValue[1].trim());
714+
} else if (allowEmptyValue) {
715+
// Handle cases where there might not be a value
716+
parameters.put(keyValue[0].trim(), "");
717+
}
718+
}
719+
603720
private String appendProxyUserToURL(String url, String user) {
604721
StringBuilder connectionUrl = new StringBuilder(url);
605722

jdbc/src/test/java/org/apache/zeppelin/jdbc/JDBCInterpreterTest.java

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,11 @@
5858
import static org.apache.zeppelin.jdbc.JDBCInterpreter.DEFAULT_URL;
5959
import static org.apache.zeppelin.jdbc.JDBCInterpreter.DEFAULT_USER;
6060
import static org.apache.zeppelin.jdbc.JDBCInterpreter.PRECODE_KEY_TEMPLATE;
61-
62-
import static org.junit.jupiter.api.Assertions.assertTrue;
61+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
62+
import static org.junit.jupiter.api.Assertions.assertEquals;
6363
import static org.junit.jupiter.api.Assertions.assertFalse;
64+
import static org.junit.jupiter.api.Assertions.assertTrue;
6465
import static org.junit.jupiter.api.Assertions.fail;
65-
import static org.junit.jupiter.api.Assertions.assertEquals;
6666

6767

6868
/**
@@ -748,33 +748,85 @@ void testSplitSqlQueryWithComments() throws IOException,
748748
}
749749

750750
@Test
751-
void testValidateConnectionUrl() throws IOException, InterpreterException {
752-
Properties properties = new Properties();
753-
properties.setProperty("default.driver", "org.h2.Driver");
754-
properties.setProperty("default.url", getJdbcConnection() + ";allowLoadLocalInfile=true");
755-
properties.setProperty("default.user", "");
756-
properties.setProperty("default.password", "");
757-
JDBCInterpreter jdbcInterpreter = new JDBCInterpreter(properties);
758-
jdbcInterpreter.open();
759-
InterpreterResult interpreterResult = jdbcInterpreter.interpret("SELECT 1", context);
760-
assertEquals(InterpreterResult.Code.ERROR, interpreterResult.code());
761-
assertEquals("Connection URL contains improper configuration",
762-
interpreterResult.message().get(0).getData());
751+
void testValidateConnectionUrlAllowLoadLocalInFile() throws IOException, InterpreterException {
752+
testBannedMySQLQueryParam("allowLoadLocalInfile=true");
753+
testBannedMySQLQueryParamWithValidParam("allowLoadLocalInfile=true");
754+
}
755+
756+
@Test
757+
void testValidateConnectionUrlAllowLoadLocal() throws IOException, InterpreterException {
758+
testBannedMySQLQueryParam("allowLoadLocal=true");
759+
}
760+
761+
@Test
762+
void testValidateConnectionUrlSocketFactory() throws IOException, InterpreterException {
763+
// it easier to unit test with H2 but this is really a Postgres issue
764+
testBannedH2QueryParam("socketFactory=com.example.MySocketFactory");
763765
}
764766

765767
@Test
766768
void testValidateConnectionUrlEncoded() throws IOException, InterpreterException {
769+
testBannedMySQLQueryParam("%61llowLoadLocalInfile=true");
770+
// also test encoded param with %2561 (%25 is the encoded form of %)
771+
testBannedMySQLQueryParam("%2561llowLoadLocalInfile=true");
772+
}
773+
774+
@Test
775+
void testValidateConnectionH2UrlWithInit() throws IOException, InterpreterException {
776+
testBannedH2QueryParam("INIT=RUNSCRIPT FROM 'http://localhost/init.sql'");
777+
}
778+
779+
@Test
780+
void testValidateConnectionMySQLProps() throws IOException, InterpreterException {
781+
testBannedURL("com.mysql.cj.jdbc.Driver",
782+
"jdbc:mysql://(host=myhost,port=1111,allowLoadLocalInfile=true)/db");
783+
}
784+
785+
@Test
786+
void testValidateConnectionMySQLProps2() throws IOException, InterpreterException {
787+
testBannedURL("com.mysql.cj.jdbc.Driver",
788+
"jdbc:mysql://[(host=myhost,port=1111,allowLoadLocalInfile=true),(host=myhost2)]/db");
789+
}
790+
791+
@Test
792+
void testValidateConnectionMySQLProps3() throws IOException, InterpreterException {
793+
testBannedURL("com.mysql.cj.jdbc.Driver",
794+
"jdbc:mysql://address=(host=172.18.0.1)(port=3309)" +
795+
"(%2561llowLoadLocalInfile=true),localhost:3306/test");
796+
}
797+
798+
@Test
799+
void testValidateConnectionMySQLWeirdPassword() throws IOException, InterpreterException {
800+
// we strongly discourage putting passwords in the URL
801+
assertDoesNotThrow(() -> JDBCInterpreter.validateConnectionUrl
802+
("jdbc:mysql://localhost:3306/test?user=xyz&password=(allowLoadLocalInfile)"));
803+
}
804+
805+
private void testBannedH2QueryParam(String param) throws IOException, InterpreterException {
806+
testBannedURL("org.h2.Driver", getJdbcConnection() + ";" + param);
807+
}
808+
809+
private void testBannedMySQLQueryParam(String param) throws IOException, InterpreterException {
810+
testBannedURL("org.h2.Driver", "jdbc:mysql://localhost/test?" + param);
811+
}
812+
813+
private void testBannedMySQLQueryParamWithValidParam(String param)
814+
throws IOException, InterpreterException {
815+
testBannedURL("org.h2.Driver", "jdbc:mysql://localhost/test?paranoid=true&" + param);
816+
}
817+
818+
private void testBannedURL(String driver, String url) throws IOException, InterpreterException {
767819
Properties properties = new Properties();
768-
properties.setProperty("default.driver", "org.h2.Driver");
769-
properties.setProperty("default.url", getJdbcConnection() + ";%61llowLoadLocalInfile=true");
820+
properties.setProperty("default.driver", driver);
821+
properties.setProperty("default.url", url);
770822
properties.setProperty("default.user", "");
771823
properties.setProperty("default.password", "");
772824
JDBCInterpreter jdbcInterpreter = new JDBCInterpreter(properties);
773825
jdbcInterpreter.open();
774826
InterpreterResult interpreterResult = jdbcInterpreter.interpret("SELECT 1", context);
775827
assertEquals(InterpreterResult.Code.ERROR, interpreterResult.code());
776828
assertEquals("Connection URL contains improper configuration",
777-
interpreterResult.message().get(0).getData());
829+
interpreterResult.message().get(0).getData());
778830
}
779831

780832
private InterpreterContext getInterpreterContext() {

0 commit comments

Comments
 (0)