From 388812ebf9d02b758f379381167fe5aa808c8d15 Mon Sep 17 00:00:00 2001 From: Luke Amdor Date: Tue, 9 Jun 2026 14:25:36 -0500 Subject: [PATCH 1/2] Escape single quotes in SQL string literals to prevent injection The OPA partial-compile-to-SQL translator interpolated string scalar values into single-quoted SQL literals without escaping embedded single quotes. Attacker-controlled string values (for example values derived from the OPA input document, such as input.subject.id) that flow into the residual query could break out of the quoted literal and inject arbitrary SQL into the generated WHERE clause. Escape embedded single quotes by doubling them, per the SQL standard, in both the SQLVisitor (src/utils/sql.jl) and the standalone reference translator used as the test oracle (test/sql_translate.jl). Adds a server-independent testset covering benign strings, quote-escaping, injection payloads, and non-string scalars. --- src/utils/sql.jl | 17 +++++++++++++++- test/runtests.jl | 46 +++++++++++++++++++++++++++++++++++++++++++ test/sql_translate.jl | 6 +++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/utils/sql.jl b/src/utils/sql.jl index 366e198..99f424f 100644 --- a/src/utils/sql.jl +++ b/src/utils/sql.jl @@ -93,6 +93,21 @@ function strip_quote(var) end end +""" + quote_string(value::AbstractString) + +Render `value` as a SQL single-quoted string literal, escaping any embedded +single quotes by doubling them (per the SQL standard). + +This prevents SQL injection when attacker-controlled string values (for +example, values derived from the OPA `input` document such as a subject id) +are interpolated into the generated `where` clause. Without escaping, a value +like `bob' or '1'='1` would terminate the literal and inject arbitrary SQL. +""" +function quote_string(value::AbstractString) + return string("'", replace(value, "'" => "''"), "'") +end + before(::SQLVisitor, _node) = nothing after(visitor::SQLVisitor, _node) = pop!(visitor.result_stack) @@ -131,7 +146,7 @@ function visit(visitor::SQLVisitor, scaler::AST.OPAScalarValue) result = (value === nothing) ? "null" : (value === true) ? "true" : (value === false) ? "false" : - isa(value, String) ? "'$value'" : + isa(value, String) ? quote_string(value) : string(value) push!(visitor.result_stack, result) return nothing diff --git a/test/runtests.jl b/test/runtests.jl index 3d0bfbd..1b597a1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -12,6 +12,7 @@ import OpenPolicyAgent.ASTWalker.SQL: SQLVisitor, SQLCondition, UnconditionalInc include("test_data.jl") include("test_utils.jl") include("sql_translate.jl") +import .OPASQL import .OPASQL: translate # Check version and help output @@ -340,6 +341,51 @@ function runtests() end end +function test_sql_string_escaping() + schema_map = Dict("data" => "public") + table_map = Dict("reports" => "juliahub_reports") + + # Helper to translate a single scalar value to its SQL literal form. + function scalar_to_sql(value) + visitor = SQLVisitor(schema_map, table_map) + SQL.visit(visitor, AST.OPAScalarValue(value)) + return pop!(visitor.result_stack) + end + + # Benign strings are still single-quoted as before. + @test scalar_to_sql("bob") == "'bob'" + @test scalar_to_sql("public") == "'public'" + + # Embedded single quotes must be doubled so they cannot break out of the + # literal and inject SQL. + @test scalar_to_sql("O'Brien") == "'O''Brien'" + @test scalar_to_sql(raw"bob' or '1'='1") == "'bob'' or ''1''=''1'" + @test scalar_to_sql("'; DROP TABLE juliahub_reports; --") == + "'''; DROP TABLE juliahub_reports; --'" + + # Non-string scalars are unaffected. + @test scalar_to_sql(4) == "4" + @test scalar_to_sql(true) == "true" + @test scalar_to_sql(false) == "false" + @test scalar_to_sql(nothing) == "null" + + # The escaped literal must stay a single, balanced SQL string literal: + # outer quotes plus an even number of interior quote characters. + for malicious in (raw"bob' or '1'='1", "O'Brien", "'; DROP TABLE x; --", "a''b") + sql = scalar_to_sql(malicious) + @test startswith(sql, "'") && endswith(sql, "'") + @test iseven(count(==('\''), sql)) + end + + # The standalone reference translator (test/sql_translate.jl) must escape + # identically, since it is used as the expected-output oracle. + @test OPASQL.to_sql(OPASQL.OPAScalarValue(raw"bob' or '1'='1")) == + "'bob'' or ''1''=''1'" +end + @testset "OpenPolicyAgent" begin + @testset "SQL string escaping" begin + test_sql_string_escaping() + end runtests() end \ No newline at end of file diff --git a/test/sql_translate.jl b/test/sql_translate.jl index bff08c7..36a07f4 100644 --- a/test/sql_translate.jl +++ b/test/sql_translate.jl @@ -178,6 +178,10 @@ function strip_quote(var) end end +# Escape embedded single quotes (per the SQL standard) so string literals +# cannot be used to inject SQL. Mirrors `OpenPolicyAgent.ASTWalker.SQL.quote_string`. +quote_string(value::AbstractString) = string("'", replace(value, "'" => "''"), "'") + function to_sql(scaler::OPAScalarValue) value = scaler.value if value === nothing @@ -187,7 +191,7 @@ function to_sql(scaler::OPAScalarValue) elseif value === false return "false" elseif isa(value, String) - return "'$value'" + return quote_string(value) else return string(value) end From d532cdb632294fb9c14ba6bcdd049db810ef48c4 Mon Sep 17 00:00:00 2001 From: tan Date: Fri, 12 Jun 2026 09:52:06 +0530 Subject: [PATCH 2/2] fix ci for 32bit environments --- test/runtests.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index 1b597a1..9b4f43c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -364,7 +364,7 @@ function test_sql_string_escaping() "'''; DROP TABLE juliahub_reports; --'" # Non-string scalars are unaffected. - @test scalar_to_sql(4) == "4" + @test scalar_to_sql(Int64(4)) == "4" @test scalar_to_sql(true) == "true" @test scalar_to_sql(false) == "false" @test scalar_to_sql(nothing) == "null" @@ -388,4 +388,4 @@ end test_sql_string_escaping() end runtests() -end \ No newline at end of file +end