Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc70b7b
fix(report): safely enable query by example
Ben-Heerema Aug 6, 2026
4477e8e
fix(report): address query review findings
Ben-Heerema Aug 6, 2026
ee4a8db
fix(report): harden schema comparison
Ben-Heerema Aug 6, 2026
233df2b
chore(report): document scanner boundaries
Ben-Heerema Aug 6, 2026
d37a0bf
test(report): complete query review follow-ups
Ben-Heerema Aug 6, 2026
5962421
fix(report): bound query by example execution
Ben-Heerema Aug 6, 2026
3f20ffb
fix(report): close query review edge cases
Ben-Heerema Aug 6, 2026
3324c57
fix(report): advertise favorite post method
Ben-Heerema Aug 6, 2026
5ebfa98
fix(report): secure query favorites
Ben-Heerema Aug 6, 2026
a6e0988
fix(report): harden favorite form markup
Ben-Heerema Aug 6, 2026
2d4ad22
test(report): include query tests in CI
Ben-Heerema Aug 7, 2026
2f99beb
fix(report): address live quality findings
Ben-Heerema Aug 7, 2026
b733a74
fix(report): clear remaining quality findings
Ben-Heerema Aug 7, 2026
85f88f2
fix(report): preserve query cleanup context
Ben-Heerema Aug 7, 2026
ef1f928
refactor(db): simplify quoted SQL scanning
Ben-Heerema Aug 7, 2026
1146b3f
fix(report): address CI review findings
Ben-Heerema Aug 7, 2026
4f210fa
fix(report): prevent query-by-example secret access
Ben-Heerema Aug 7, 2026
1cda36b
test(report): verify disabled query is not executed
Ben-Heerema Aug 7, 2026
d7671c7
refactor(report): address static review findings
Ben-Heerema Aug 7, 2026
41e6d5d
test(report): prevent sensitive table policy drift
Ben-Heerema Aug 7, 2026
a15394e
fix(report): render numeric query results
Ben-Heerema Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions dependencies-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
<owasp-encoder.version>1.4.0</owasp-encoder.version>
<!-- OWASP CSRFGuard -->
<csrfguard.version>4.5.0-jakarta</csrfguard.version>
<!-- Structural validation for authorized Query-by-Example SELECTs -->
<jsqlparser.version>5.3</jsqlparser.version>
<!-- JNA: pin transitive ultrabuk-htmltopdf-java range [5.12,) to the locked version -->
<jna.version>5.19.0</jna.version>
<!-- HAPI HL7 v2 -->
Expand Down Expand Up @@ -445,6 +447,20 @@
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
</dependency>

<!-- SQL parsing used to fail closed before Query-by-Example reaches JDBC -->
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>${jsqlparser.version}</version>
<exclusions>
<!-- Benchmarking support is not used by the parser at runtime. -->
<exclusion>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- ==================== HTTP Client ==================== -->

<!-- Apache HttpClient 5.x — migrated from 4.5.14 (httpclient + httpmime merged into httpclient5) -->
Expand Down Expand Up @@ -1446,6 +1462,13 @@
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.14</version>
<configuration>
<!-- JSqlParser's generated token-manager method exceeds the JVM method
size after instrumentation; dependency code is outside our coverage scope. -->
<excludes>
<exclude>net.sf.jsqlparser.*</exclude>
</excludes>
</configuration>
<executions>
<execution>
<goals>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,21 +332,22 @@ public static void validateSafeSelectQuery(String sql) throws SQLException {
throw new SQLException("Potential SQL injection pattern detected");
}

if (containsSqlWord(normalized, "union")) {
String normalizedSqlSyntax = stripQuotedSqlSections(sql).toLowerCase(Locale.ROOT);
if (containsSqlWord(normalizedSqlSyntax, "union")) {
throw new SQLException("Unsafe SQL detected: UNION not permitted");
}

String[] blockedWords = {"insert", "update", "delete", "drop", "alter", "create", "truncate",
"grant", "revoke", "exec", "execute", "call", "merge", "commit", "rollback"};
for (String word : blockedWords) {
if (containsSqlWord(normalized, word)) {
if (containsSqlWord(normalizedSqlSyntax, word)) {
throw new SQLException("Unsafe SQL detected: prohibited keyword");
}
}

String[] blockedPhrases = {"into outfile", "into dumpfile", "load_file", "load data"};
for (String phrase : blockedPhrases) {
if (normalized.contains(phrase)) {
if (normalizedSqlSyntax.contains(phrase)) {
throw new SQLException("Unsafe SQL detected: prohibited keyword");
}
}
Expand Down Expand Up @@ -615,6 +616,35 @@ private static boolean containsSqlWord(String sql, String word) {
return false;
}

private static String stripQuotedSqlSections(String sql) {
Comment thread
Ben-Heerema marked this conversation as resolved.
StringBuilder stripped = new StringBuilder(sql.length());
char quote = '\0';
for (int i = 0; i < sql.length(); i++) {
char current = sql.charAt(i);
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
if (quote == '\0') {
if (current == '\'' || current == '"' || current == '`') {
quote = current;
stripped.append(' ');
} else {
stripped.append(current);
}
} else if (quote != '`' && current == '\\' && next != '\0') {
stripped.append(" ");
i++;
} else if (current == quote && next == quote) {
stripped.append(" ");
i++;
} else if (current == quote) {
quote = '\0';
stripped.append(' ');
} else {
stripped.append(' ');
}
}
return stripped.toString();
}

private static boolean startsWithSqlWord(String sql, String word) {
return sql.startsWith(word)
&& (sql.length() == word.length() || !isSqlIdentifierPart(sql.charAt(word.length())));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Copyright (c) 2026 CARLOS Contributors. All Rights Reserved.
*
* This software is published under the GPL GNU General Public License.
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*
* CARLOS EMR Project
* https://github.com/carlos-emr/carlos
*/
package io.github.carlos_emr.carlos.report.data;

import java.sql.SQLException;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Pattern;

import io.github.carlos_emr.carlos.db.LegacyJdbcQuery;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SetOperationList;
import net.sf.jsqlparser.util.TablesNamesFinder;

/** Fail-closed validation for request-submitted Query-by-Example SQL. */
public final class QueryByExampleSqlValidator {
private static final Pattern LOCKING_SELECT = Pattern.compile(
"\\bfor\\s+(?:update|share)\\b|\\block\\s+in\\s+share\\s+mode\\b",
Pattern.CASE_INSENSITIVE);
private static final Pattern OUTPUT_OPERATION = Pattern.compile("\\binto\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern BLOCKED_FUNCTION = Pattern.compile(
"(?<![a-z0-9_$])`?(?:sleep|benchmark|get_lock|release_lock|is_free_lock|load_file)`?\\s*\\(",
Pattern.CASE_INSENSITIVE);

private QueryByExampleSqlValidator() {
}

public static LegacyJdbcQuery.TrustedSql validate(String sql, Properties properties)
throws QueryByExampleValidationException {
Comment thread
Ben-Heerema marked this conversation as resolved.
Outdated
if (sql == null || sql.isBlank()) {
throw new QueryByExampleValidationException("SQL query must not be empty");
}

Statement statement;
try {
statement = CCJSqlParserUtil.parse(sql);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
Ben-Heerema marked this conversation as resolved.
} catch (JSQLParserException | RuntimeException e) {
throw new QueryByExampleValidationException("The query could not be parsed as a single SELECT", e);
}

if (!(statement instanceof Select select) || select instanceof SetOperationList) {
throw new QueryByExampleValidationException("Only one SELECT statement is allowed");
}

String sqlWithoutStringLiterals = stripStringLiterals(sql);
if (LOCKING_SELECT.matcher(sqlWithoutStringLiterals).find()) {
throw new QueryByExampleValidationException("Locking SELECT statements are not allowed");
}
if (OUTPUT_OPERATION.matcher(sqlWithoutStringLiterals).find()) {
Comment thread
Ben-Heerema marked this conversation as resolved.
Outdated
throw new QueryByExampleValidationException("SELECT output operations are not allowed");
}
Comment thread
Ben-Heerema marked this conversation as resolved.
rejectBlockedFunctions(sqlWithoutStringLiterals);
Comment thread
Ben-Heerema marked this conversation as resolved.
Outdated
Comment thread
Ben-Heerema marked this conversation as resolved.
Outdated
rejectOtherSchemas(statement, applicationSchema(properties));
try {
return LegacyJdbcQuery.trustedSelectSql(sql);
} catch (SQLException e) {
throw new QueryByExampleValidationException(e.getMessage(), e);
}
}

static String applicationSchema(Properties properties) throws QueryByExampleValidationException {
String configuredName = properties == null ? null : properties.getProperty("db_name");
if (configuredName == null || configuredName.isBlank()) {
throw new QueryByExampleValidationException("The application database schema is not configured");
}
String schema = configuredName.split("\\?", 2)[0].trim();
if (schema.isEmpty()) {
throw new QueryByExampleValidationException("The application database schema is not configured");
}
return unquoteIdentifier(schema);
}

private static void rejectOtherSchemas(Statement statement, String applicationSchema)
throws QueryByExampleValidationException {
Set<String> tables;
try {
tables = new TablesNamesFinder<Void>().getTables(statement);
} catch (RuntimeException e) {
throw new QueryByExampleValidationException("The query table references could not be validated", e);
}
for (String table : tables) {
String normalizedTable = unquoteIdentifier(table);
int lastDot = normalizedTable.lastIndexOf('.');
if (lastDot > 0) {
String qualifier = normalizedTable.substring(0, lastDot);
if (!qualifier.equalsIgnoreCase(applicationSchema)) {

Check notice

Code scanning / SpotBugs + Find Security Bugs

IMPROPER_UNICODE Low

Improper handling of Unicode transformations such as case mapping and normalization.
throw new QueryByExampleValidationException("Queries may only read the application database schema");
}
}
}
}

private static void rejectBlockedFunctions(String sql) throws QueryByExampleValidationException {
if (BLOCKED_FUNCTION.matcher(sql).find()) {
throw new QueryByExampleValidationException("The query uses a prohibited database function");
}
}

private static String stripStringLiterals(String sql) {
StringBuilder stripped = new StringBuilder(sql.length());
char quote = '\0';
for (int i = 0; i < sql.length(); i++) {
char current = sql.charAt(i);
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
if (quote == '\0') {
if (current == '\'' || current == '"') {
quote = current;
stripped.append(' ');
} else {
stripped.append(current);
}
} else if (current == '\\' && next != '\0') {
stripped.append(" ");
i++;
} else if (current == quote && next == quote) {
stripped.append(" ");
i++;
} else if (current == quote) {
quote = '\0';
stripped.append(' ');
} else {
stripped.append(' ');
}
}
return stripped.toString();
}

private static String unquoteIdentifier(String identifier) {
return identifier.replace("`", "").replace("\"", "").trim();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2026 CARLOS Contributors. All Rights Reserved.
*
* This software is published under the GPL GNU General Public License.
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
*
* CARLOS EMR Project
* https://github.com/carlos-emr/carlos
*/
package io.github.carlos_emr.carlos.report.data;

import java.sql.SQLException;

/** Indicates that a Query-by-Example submission was rejected before execution. */
public class QueryByExampleValidationException extends SQLException {
Comment thread
Ben-Heerema marked this conversation as resolved.
public QueryByExampleValidationException(String message) {
super(message);
}

public QueryByExampleValidationException(String message, Throwable cause) {
super(message, cause);
}
Comment thread
Ben-Heerema marked this conversation as resolved.
Outdated
}
Loading
Loading