From 2796678de1f3a88f0ed3038f2501ee1afc5dda90 Mon Sep 17 00:00:00 2001 From: grabdoc <57690705+grabdoc@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:07:29 -0500 Subject: [PATCH 1/4] test: validate quoted PostgreSQL table names --- .../PostgreSQLContainerConfiguration.java | 3 +- .../rest/pg/PgCaseSensitiveTableNameTest.java | 38 +++++++++++++++++++ .../pg/postgres-case-sensitive-table.sql | 10 +++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java create mode 100644 db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql diff --git a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/PostgreSQLContainerConfiguration.java b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/PostgreSQLContainerConfiguration.java index b778f92c..3412cd73 100644 --- a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/PostgreSQLContainerConfiguration.java +++ b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/PostgreSQLContainerConfiguration.java @@ -19,7 +19,8 @@ public class PostgreSQLContainerConfiguration { private static final List postgresScripts = List.of("pg/postgres-sakila.sql", - "pg/postgres-sakila-data.sql", "pg/pg-sakila-functions.sql"); + "pg/postgres-sakila-data.sql", "pg/pg-sakila-functions.sql", + "pg/postgres-case-sensitive-table.sql"); private static final PostgreSQLContainer testPostgres = (PostgreSQLContainer) new PostgreSQLContainer("postgres:15.2-alpine") diff --git a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java new file mode 100644 index 00000000..1fefc85b --- /dev/null +++ b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java @@ -0,0 +1,38 @@ +package com.homihq.db2rest.rest.pg; + +import com.homihq.db2rest.PostgreSQLBaseIntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.homihq.db2rest.rest.RdbmsRestApi.VERSION; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PgCaseSensitiveTableNameTest extends PostgreSQLBaseIntegrationTest { + + @Test + @DisplayName("Read a quoted mixed-case PostgreSQL table using its exact URL name") + void readCaseSensitiveTableUsingExactName() throws Exception { + mockMvc.perform(get(VERSION + "/pgsqldb/FilmData") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].film_id").value(916)) + .andExpect(jsonPath("$[0].title").value("Case-sensitive table names")); + } + + @Test + @DisplayName("Read a quoted mixed-case PostgreSQL table from an explicit schema") + void readCaseSensitiveTableUsingSchemaHeader() throws Exception { + mockMvc.perform(get(VERSION + "/pgsqldb/FilmData") + .header("Accept-Profile", "stg") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].film_id").value(916)) + .andExpect(jsonPath("$[0].title").value("Case-sensitive table names")); + } +} diff --git a/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql b/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql new file mode 100644 index 00000000..c5af6bf8 --- /dev/null +++ b/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql @@ -0,0 +1,10 @@ +DROP SCHEMA IF EXISTS stg CASCADE; +CREATE SCHEMA stg; + +CREATE TABLE stg."FilmData" ( + film_id integer PRIMARY KEY, + title character varying(100) NOT NULL +); + +INSERT INTO stg."FilmData" (film_id, title) +VALUES (916, 'Case-sensitive table names'); From 7a710fd6bd011ee8094938cd29dacbf134788380 Mon Sep 17 00:00:00 2001 From: grabdoc <57690705+grabdoc@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:22:21 -0500 Subject: [PATCH 2/4] fix: restore SQL template GET routing --- .../rest/sql/SQLTemplateController.java | 19 +++++++++++++++++++ .../sqlite/SQLiteUpdateControllerTest.java | 9 ++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/db2rest-api/api-rest/src/main/java/com/homihq/db2rest/rest/sql/SQLTemplateController.java b/db2rest-api/api-rest/src/main/java/com/homihq/db2rest/rest/sql/SQLTemplateController.java index e09914c4..1c3866c0 100644 --- a/db2rest-api/api-rest/src/main/java/com/homihq/db2rest/rest/sql/SQLTemplateController.java +++ b/db2rest-api/api-rest/src/main/java/com/homihq/db2rest/rest/sql/SQLTemplateController.java @@ -24,6 +24,16 @@ public class SQLTemplateController { private final SQLTemplateExecutorService sqlTemplateExecutorService; + @GetMapping("/{fileName}") + public Object sqlTemplate(@PathVariable String dbId, + @PathVariable String fileName, + @RequestParam Map requestParams, + @RequestHeader Map requestHeaders, + @MatrixVariable Map matrixVariables + ) { + return executeGetTemplate(dbId, fileName, "", requestParams, requestHeaders, matrixVariables); + } + @GetMapping("/{fileName}/{*userPathVariable}") public Object sqlTemplate(@PathVariable String dbId, @PathVariable String fileName, @@ -32,6 +42,15 @@ public Object sqlTemplate(@PathVariable String dbId, @RequestHeader Map requestHeaders, @MatrixVariable Map matrixVariables ) { + return executeGetTemplate(dbId, fileName, userPathVariable, requestParams, requestHeaders, matrixVariables); + } + + private Object executeGetTemplate(String dbId, + String fileName, + String userPathVariable, + Map requestParams, + Map requestHeaders, + Map matrixVariables) { final Map context = createContext(requestParams, requestHeaders, matrixVariables); Map paths = createPaths(userPathVariable, requestHeaders); context.put("paths", paths); diff --git a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/sqlite/SQLiteUpdateControllerTest.java b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/sqlite/SQLiteUpdateControllerTest.java index cda2fef0..1e60c7a6 100644 --- a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/sqlite/SQLiteUpdateControllerTest.java +++ b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/sqlite/SQLiteUpdateControllerTest.java @@ -185,14 +185,17 @@ void updateNonExistentRecord() throws Exception { void updateWithInvalidPayload() throws Exception { Map invalidUpdate = Map.of( - "language_id", "invalid_value" + "unknown_column", "invalid_value" ); - mockMvc.perform(patch(VERSION + "/sqlitedb/film/1") + mockMvc.perform(patch(VERSION + "/sqlitedb/film") + .param("filter", "film_id==1") .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidUpdate))) .andDo(print()) .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCategory", equalTo("Data-access-error"))) + .andExpect(jsonPath("$.detail", equalTo("Column not found film.unknown_column"))) .andDo(document("sqlite-update-invalid-payload")); } -} \ No newline at end of file +} From 6be7888c3d897bbe7271c49200d6558fd338ed39 Mon Sep 17 00:00:00 2001 From: grabdoc <57690705+grabdoc@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:41:18 -0500 Subject: [PATCH 3/4] fix: validate SQL template file names --- .../JinJavaTemplateExecutorService.java | 22 ++++++++++-- .../JinJavaTemplateExecutorServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 db2rest-core/rdbms-support/src/test/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorServiceTest.java diff --git a/db2rest-core/rdbms-support/src/main/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorService.java b/db2rest-core/rdbms-support/src/main/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorService.java index c00079f0..0093bc20 100644 --- a/db2rest-core/rdbms-support/src/main/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorService.java +++ b/db2rest-core/rdbms-support/src/main/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorService.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -35,6 +36,8 @@ public class JinJavaTemplateExecutorService implements SQLTemplateExecutorService { private static final String SQL_TEMPLATE_EXTENSION = ".sql"; + private static final Pattern SQL_TEMPLATE_NAME_PATTERN = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); private static final String PLACEHOLDER_REGEX = "\\{\\{\\s*([^|}]+?)\\s*(?:\\|\\s*([^}]+?))*\\s*}}"; private final Jinjava jinjava; @@ -143,6 +146,7 @@ private Pair> executeInternal(String templateFile, M private String renderJinJavaTemplate(String templateFile, Map context) { log.debug("Rendering query from template {}", templateFile); + validateTemplateName(templateFile); synchronized (templateCache) { if (templateCache.containsKey(templateFile)) { @@ -150,8 +154,12 @@ private String renderJinJavaTemplate(String templateFile, Map co return jinjava.render(templateContent, context); } else { final String userTemplateLocation = db2RestConfigProperties.getTemplates(); - final Path templatePath = Paths.get(userTemplateLocation, templateFile + SQL_TEMPLATE_EXTENSION); - if (!Files.exists(templatePath)) { + final Path templateDirectory = Paths.get(userTemplateLocation).toAbsolutePath().normalize(); + final Path templatePath = templateDirectory + .resolve(templateFile + SQL_TEMPLATE_EXTENSION) + .normalize(); + if (!templatePath.startsWith(templateDirectory) + || !Files.isRegularFile(templatePath, LinkOption.NOFOLLOW_LINKS)) { throw new SqlTemplateNotFoundException(templateFile); } try { @@ -166,6 +174,16 @@ private String renderJinJavaTemplate(String templateFile, Map co } } + private void validateTemplateName(String templateFile) { + if (templateFile == null + || templateFile.contains("..") + || templateFile.contains("/") + || templateFile.contains("\\") + || !SQL_TEMPLATE_NAME_PATTERN.matcher(templateFile).matches()) { + throw new SqlTemplateNotFoundException(templateFile); + } + } + private Map extractPlaceHolder(final String renderedSqlTemplate) { log.debug("Extracting placeholder from sql template"); diff --git a/db2rest-core/rdbms-support/src/test/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorServiceTest.java b/db2rest-core/rdbms-support/src/test/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorServiceTest.java new file mode 100644 index 00000000..459d9b43 --- /dev/null +++ b/db2rest-core/rdbms-support/src/test/java/com/homihq/db2rest/jdbc/core/service/JinJavaTemplateExecutorServiceTest.java @@ -0,0 +1,35 @@ +package com.homihq.db2rest.jdbc.core.service; + +import com.homihq.db2rest.core.exception.SqlTemplateNotFoundException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JinJavaTemplateExecutorServiceTest { + + private final JinJavaTemplateExecutorService templateExecutorService = + new JinJavaTemplateExecutorService(null, null, null, null, null); + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + " ", + ".hidden", + "../secret", + "..\\secret", + "template..name", + "/absolute/path", + "nested/template", + "nested\\template", + "template name", + "template%2Fname" + }) + void rejectsUnsafeTemplateNames(String templateName) { + assertThatThrownBy(() -> templateExecutorService.execute("db", templateName, Map.of())) + .isInstanceOf(SqlTemplateNotFoundException.class); + } +} From 828ad74b24db82464f6c9eeb1948e40fea28538c Mon Sep 17 00:00:00 2001 From: grabdoc <57690705+grabdoc@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:19:04 -0500 Subject: [PATCH 4/4] test: cover Unicode PostgreSQL table names --- .../rest/pg/PgCaseSensitiveTableNameTest.java | 12 ++++++++++++ .../resources/pg/postgres-case-sensitive-table.sql | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java index 1fefc85b..bc7a7165 100644 --- a/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java +++ b/db2rest-api/api-rest/src/test/java/com/homihq/db2rest/rest/pg/PgCaseSensitiveTableNameTest.java @@ -35,4 +35,16 @@ void readCaseSensitiveTableUsingSchemaHeader() throws Exception { .andExpect(jsonPath("$[0].film_id").value(916)) .andExpect(jsonPath("$[0].title").value("Case-sensitive table names")); } + + @Test + @DisplayName("Read a PostgreSQL table with a Unicode name") + void readUnicodeTableNameUsingSchemaHeader() throws Exception { + mockMvc.perform(get(VERSION + "/pgsqldb/überall") + .header("Accept-Profile", "stg") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].film_id").value(917)) + .andExpect(jsonPath("$[0].title").value("Unicode table names")); + } } diff --git a/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql b/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql index c5af6bf8..15807b71 100644 --- a/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql +++ b/db2rest-api/api-rest/src/test/resources/pg/postgres-case-sensitive-table.sql @@ -8,3 +8,11 @@ CREATE TABLE stg."FilmData" ( INSERT INTO stg."FilmData" (film_id, title) VALUES (916, 'Case-sensitive table names'); + +CREATE TABLE stg."überall" ( + film_id integer PRIMARY KEY, + title character varying(100) NOT NULL +); + +INSERT INTO stg."überall" (film_id, title) +VALUES (917, 'Unicode table names');