Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2024 Contributors to the Eclipse Foundation.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* SmartCity Jena - initial
* Stefan Bischof (bipolis.org) - initial
*/

package org.eclipse.daanse.sql.guard.jsqltranspiler;

import java.util.Optional;

import net.sf.jsqlparser.statement.ParenthesedStatement;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.ParenthesedSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.WithItem;
import net.sf.jsqlparser.statement.delete.ParenthesedDelete;
import net.sf.jsqlparser.statement.insert.ParenthesedInsert;
import net.sf.jsqlparser.statement.update.ParenthesedUpdate;
import net.sf.jsqlparser.util.TablesNamesFinder;

/**
* Walks a parsed {@link Select} tree and detects data-modifying statements that
* are wrapped in a {@code WITH} item (a data-modifying CTE), such as
* {@code WITH x AS (UPDATE foo ...) SELECT ...}.
* <p>
* Such statements parse as a {@code Select} and therefore pass the top-level
* {@code instanceof Select} gate, yet on PostgreSQL/MariaDB they execute the
* wrapped {@code UPDATE}/{@code INSERT}/{@code DELETE}. The upstream resolver
* does not populate its update/insert column lists for a {@code WithItem}, so it
* cannot be relied on to detect this case.
* <p>
* This validator reuses {@link TablesNamesFinder}'s full recursive traversal
* (subqueries, joins, set operations, nested {@code WITH} clauses) and inspects
* every {@code WithItem}. A {@code WithItem} is only permitted to wrap a
* {@link ParenthesedSelect}; anything else is a violation.
*/
class ReadOnlyStatementValidator extends TablesNamesFinder<Void> {

static final String DELETE_IS_NOT_PERMITTED = "DELETE is not permitted.";
static final String UPDATE_IS_NOT_PERMITTED = "UPDATE is not permitted.";
static final String INSERT_IS_NOT_PERMITTED = "INSERT is not permitted.";
static final String STATEMENT_IS_NOT_PERMITTED = "Statement is not permitted.";

private String violation;

/**
* Runs the read-only traversal over the given select and returns the first
* violation message, or an empty optional when the statement is read-only.
*/
Optional<String> firstViolation(Select select) {
violation = null;
// drives the recursive traversal; the returned table set is irrelevant
getTables((Statement) select);
return Optional.ofNullable(violation);
}

@Override
public <S> Void visit(WithItem<?> withItem, S context) {
ParenthesedStatement statement = withItem.getParenthesedStatement();
if (statement instanceof ParenthesedSelect) {
// read-only CTE body: keep descending so nested WITH clauses are inspected too.
// The base implementation resolves getSelect(), which is only valid here.
return super.visit(withItem, context);
}
// Anything other than a ParenthesedSelect is a data-modifying (or otherwise
// unexpected) CTE. Record it and do NOT descend: getSelect() would throw.
if (violation == null) {
if (statement instanceof ParenthesedUpdate) {
violation = UPDATE_IS_NOT_PERMITTED;
} else if (statement instanceof ParenthesedInsert) {
violation = INSERT_IS_NOT_PERMITTED;
} else if (statement instanceof ParenthesedDelete) {
violation = DELETE_IS_NOT_PERMITTED;
} else {
violation = STATEMENT_IS_NOT_PERMITTED;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -90,6 +91,13 @@ public String guard(String sqlStr) throws GuardException {

// we can test for SELECT, though in practise it won't protect us from harmful statements
if (st instanceof Select) {

// A statement can parse as a Select yet still carry a data-modifying
// statement wrapped in a WITH item (e.g. WITH x AS (UPDATE ...) SELECT ...).
// The resolver does not populate its update/insert column lists for that
// case, so reject it explicitly before anything else.
validateReadOnly((Select) st);

resolver.resolve(st);

// select columns should not be empty
Expand Down Expand Up @@ -172,6 +180,18 @@ public String guard(String sqlStr) throws GuardException {

}

private static void validateReadOnly(Select select) throws GuardException {
Optional<String> violation = new ReadOnlyStatementValidator().firstViolation(select);
if (violation.isPresent()) {
String message = violation.get();
LOGGER.atInfo().log(message);
if (ReadOnlyStatementValidator.STATEMENT_IS_NOT_PERMITTED.equals(message)) {
throw new UnallowedStatementTypeGuardException(message);
}
throw new GuardException(message);
}
}

private static JdbcMetaData calculateMetaData(String currentCatalogName, String currentSchemaName,
DatabaseCatalog databaseCatalog) {
JdbcMetaData jdbcMetaData = new JdbcMetaData(currentCatalogName, currentSchemaName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,33 @@ public class SqlGuardTest {
WITH cte1 AS (SELECT foo.id FROM foo),
cte2 AS (SELECT fooFact.wrongCol FROM fooFact)
SELECT * FROM cte1, cte2""";

private static final String SQL_CTE_UPDATE = """
WITH x AS (UPDATE foo SET name='pwned' WHERE id=1 RETURNING id) SELECT id FROM foo""";

private static final String SQL_CTE_UPDATE_ALL_ROWS = """
WITH x AS (UPDATE foo SET name='pwned') SELECT id FROM foo""";

private static final String SQL_MULTIPLE_CTE_UPDATE = """
WITH x AS (UPDATE foo SET name='a' RETURNING id),
y AS (UPDATE foo SET name='b' RETURNING id)
SELECT id FROM foo""";

private static final String SQL_CTE_INSERT = """
WITH x AS (INSERT INTO foo (id, name) VALUES (1, 'a') RETURNING id) SELECT id FROM foo""";

private static final String SQL_CTE_DELETE = """
WITH x AS (DELETE FROM foo WHERE id=1 RETURNING id) SELECT id FROM foo""";

private static final String SQL_NESTED_SUBQUERY_CTE_UPDATE = """
SELECT foo.id FROM foo
WHERE foo.id IN (SELECT sub.id FROM (WITH y AS (UPDATE foo SET name='z' RETURNING id) SELECT id FROM foo) sub)""";

private static final String UPDATE_IS_NOT_PERMITTED = "UPDATE is not permitted.";

private static final String INSERT_IS_NOT_PERMITTED = "INSERT is not permitted.";

private static final String DELETE_IS_NOT_PERMITTED = "DELETE is not permitted.";
private static final String SQL_LEFT_JOIN_ON_WRONG_COLUMN = """
SELECT foo.id FROM foo LEFT JOIN fooFact ON foo.wrongCol = fooFact.id""";

Expand Down Expand Up @@ -993,6 +1020,75 @@ void testMultipleCteWrongColumn(@InjectService SqlGuardFactory sqlGuardFactory)
}
}

@Nested
@DisplayName("CTE Data-Modifying (DML) Security Tests")
class CteDmlSecurityTests {

// A data-modifying CTE parses as a Select and resolves against the whitelisted
// table, so it must be rejected by the read-only gate, not the column resolver.

@Test
void testCteUpdate(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_CTE_UPDATE))
.isInstanceOf(GuardException.class)
.hasMessage(UPDATE_IS_NOT_PERMITTED);
}

@Test
void testCteUpdateAllRows(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_CTE_UPDATE_ALL_ROWS))
.isInstanceOf(GuardException.class)
.hasMessage(UPDATE_IS_NOT_PERMITTED);
}

@Test
void testMultipleCteUpdate(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_MULTIPLE_CTE_UPDATE))
.isInstanceOf(GuardException.class)
.hasMessage(UPDATE_IS_NOT_PERMITTED);
}

@Test
void testCteInsert(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_CTE_INSERT))
.isInstanceOf(GuardException.class)
.hasMessage(INSERT_IS_NOT_PERMITTED);
}

@Test
void testCteDelete(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_CTE_DELETE))
.isInstanceOf(GuardException.class)
.hasMessage(DELETE_IS_NOT_PERMITTED);
}

@Test
void testNestedSubqueryCteUpdate(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertThatThrownBy(() -> guard.guard(SQL_NESTED_SUBQUERY_CTE_UPDATE))
.isInstanceOf(GuardException.class)
.hasMessage(UPDATE_IS_NOT_PERMITTED);
}

@Test
void testValidCteStillPasses(@InjectService SqlGuardFactory sqlGuardFactory) throws Exception {
DatabaseCatalog databaseCatalog = schemaWithTwoTableTwoCol();
SqlGuard guard = sqlGuardFactory.create("", SCH, databaseCatalog, List.of(), dialect);
assertNotNull(guard.guard(SQL_VALID_CTE));
}
}

@Nested
@DisplayName("JOIN Security Tests")
class JoinSecurityTests {
Expand Down
Loading