cel2sql includes comprehensive security protections against common attack vectors. All security features are enabled by default with zero configuration required.
- Overview
- Field Name Validation
- JSON Field Escaping
- ReDoS Protection
- Recursion Depth Limits
- Context Timeouts
- Best Practices
- Security Checklist
cel2sql protects against:
| Threat | Protection | Status |
|---|---|---|
| SQL Injection (field names) | Field name validation | ✅ Automatic |
| SQL Injection (JSON fields) | Quote escaping | ✅ Automatic |
| ReDoS (Regex DoS) | Pattern validation | ✅ Automatic |
| Stack overflow (deep nesting) | Recursion depth limits | ✅ Automatic |
| Resource exhaustion | Context timeouts | ⚙️ Optional |
| Information disclosure | Schema minimization | 📋 Manual |
All automatic protections require no configuration and are always active.
SQL injection via malicious field names:
// ❌ Attack attemptfield'; DROPTABLEusers; --// ❌ Attack attemptSELECT*FROMsensitive_data// ❌ Attack attemptuserOR1=1All field names are validated against strict rules before SQL generation:
Validation Rules:
- Length: Maximum 63 characters (PostgreSQL
NAMEDATALEN-1) - Format: Must start with letter or underscore (
[a-zA-Z_]) - Characters: Only alphanumeric and underscore (
[a-zA-Z0-9_]*) - Reserved words: Blocks 60+ SQL reserved keywords
- Empty: Empty strings are rejected
Implementation:
- Validation occurs in
utils.go:validateFieldName() - Applied in
cel2sql.go:visitSelect()andvisitIdent()
// ✅ Valid field namesuser.nameproduct.price_usdorder_item_123_internal_field// ❌ Invalid field namesfield'; DROPTABLEusers--// SQL injection attempt123invalid// Starts with numberfield-name// Contains hyphenSELECT// Reserved keyworduser.field_with_very_long_name_that_exceeds_the_postgresql_maximum_identifier_length// Too longast, _:=env.Compile(`field'; DROP TABLE users--.value == 1`)
sql, err:=cel2sql.Convert(ast)
// err: "invalid field name 'field'; DROP TABLE users--': must start with letter or underscore and contain only alphanumeric characters and underscores"ast, _=env.Compile(`SELECT.value == 1`)
sql, err=cel2sql.Convert(ast)
// err: "invalid field name 'SELECT': reserved SQL keyword"The following SQL keywords are blocked:
SELECT, INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE,
FROM, WHERE, JOIN, UNION, HAVING, GROUP, ORDER, BY, AS, ON,
AND, OR, NOT, NULL, TRUE, FALSE, CASE, WHEN, THEN, ELSE, END,
IN, EXISTS, BETWEEN, LIKE, IS, DISTINCT, ALL, ANY, SOME,
PRIMARY, FOREIGN, KEY, REFERENCES, CONSTRAINT, INDEX, TABLE,
DATABASE, SCHEMA, VIEW, PROCEDURE, FUNCTION, TRIGGER, GRANT,
REVOKE, COMMIT, ROLLBACK, TRANSACTION
SQL injection via malicious JSON field names:
// ❌ Attack attemptuser.preferences.field' OR'1'='1
// ❌ Attack attemptuser.data.key'; DROPTABLEusers--Single quotes in JSON field names are automatically escaped by doubling them:
' → ''
This follows PostgreSQL's standard for escaping quotes in string literals.
Implementation:
- Escaping occurs in
utils.go:escapeJSONFieldName() - Applied in multiple locations:
cel2sql.go:visitSelect()- JSON path operatorscel2sql.go:visitHasFunction()- Existence checkscel2sql.go:visitNestedJSONHas()- Path extractionjson.go:buildJSONPath*()- All JSON path construction
// Field name with single quoteuser.preferences.user'name=="test"// Generated SQL: user.preferences->>'user''name' = 'test'// Malicious field name (neutralized)user.data.field' OR'1'='1 =="value"// Generated SQL: user.data->>'field'' OR ''1''=''1' = 'value'// Multiple quotesuser.settings.key''with''quotes=="test"// Generated SQL: user.settings->>'key''''with''''quotes' = 'test'All JSON operations are protected:
| Operation | Operator | Example |
|---|---|---|
| Field access | ->> | user.data->>'key''name' |
| Nested access | -> | user.data->'nested''key' |
| Existence check | ? | user.data ? 'key''name' |
| Path extraction | jsonb_extract_path_text() | jsonb_extract_path_text(user.data, 'key''name') |
JSON field escaping is applied at multiple stages:
- visitSelect() - When constructing field access
- buildJSONPath() - When building JSON path expressions
- visitHasFunction() - When checking field existence
- visitNestedJSONHas() - When extracting nested paths
This ensures protection even if one layer is bypassed.
Regular Expression Denial of Service (ReDoS) attacks that cause catastrophic backtracking:
// ❌ Dangerous pattern - can freeze your application
field.matches(r"(a+)+b")
// Input "aaaaaaaaaaaaaaaaaaX" → HOURS of processing time// ❌ Quantified alternationfield.matches(r"(a|a)*b")
// Can cause exponential time complexityCVE References:
- Similar to CVE-2019-16155 (ReDoS in third-party libraries)
- CWE-1333: Inefficient Regular Expression Complexity
All regex patterns are validated before conversion using a multi-layered approach:
Implementation:
- Pattern validation in
cel2sql.go:convertRE2ToPOSIX() - Validation occurs before pattern conversion
- Descriptive errors returned for blocked patterns
Prevents resource exhaustion from extremely long patterns:
// ✅ Normal patterns allowed
email.matches(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") // 62 chars// ❌ Excessive length blocked
field.matches(r"verylongpattern..." *100) // > 500 chars// Error: pattern exceeds 500 character limitUses state machine to detect nested quantifiers:
// ❌ Blocked: Nested + quantifiers
field.matches(r"(a+)+")
// Error: nested quantifiers detected at position 4// ❌ Blocked: Nested * quantifiers
field.matches(r"(a*)*")
// Error: nested quantifiers detected// ❌ Blocked: Mixed nesting
field.matches(r"(a+)*b")
// Error: nested quantifiers detected// ❌ Blocked: Deep nestingfield.matches(r"((a+)+)+")
// Error: nested quantifiers detected// ✅ Allowed: Non-nested quantifiersfield.matches(r"(abc)+") // OK
field.matches(r"a+b+c+") // OK
field.matches(r"[a-z]+") // OKPrevents memory exhaustion from excessive groups:
// ✅ Reasonable groups allowed
field.matches(r"(a)(b)(c)(d)(e)") // 5 groups// ❌ Too many groups blockedfield.matches(r"(a)(b)(c)...(z)(aa)(ab)(ac)") // > 20 groups
// Error: pattern exceeds 20 capture group limitDetects problematic alternation patterns:
// ❌ Blocked: Quantified alternationfield.matches(r"(a|a)+b")
// Error: quantified alternation detected// ❌ Blocked: Subtle alternation issuefield.matches(r"(x|xy)+y")
// Error: quantified alternation detected// ❌ Blocked: Overlapping alternativesfield.matches(r"(ab|a)+c")
// Error: quantified alternation detected// ✅ Allowed: Safe alternation without quantifiers
field.matches(r"(cat|dog|bird)") // OKfield.matches(r"^(active|inactive)$") // OKPrevents stack exhaustion from deeply nested patterns:
// ✅ Reasonable nesting allowed
field.matches(r"((abc))") // 2 levels// ❌ Excessive nesting blocked
field.matches(r"((((((((((a))))))))))") // 10+ levels// Error: pattern exceeds nesting depth limit of 10These patterns are validated as safe:
// ✅ Email validationemail.matches(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
// ✅ Phone number (US format)
phone.matches(r"^\d{3}-\d{3}-\d{4}$")
// ✅ URL validationurl.matches(r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")// ✅ Username (alphanumeric, 3-20 chars)
username.matches(r"^[a-zA-Z0-9_-]{3,20}$")
// ✅ Zip code (US)zipcode.matches(r"^\d{5}(-\d{4})?$")
// ✅ Case-insensitive searchdescription.matches(r"(?i)urgent|priority|important")
// ✅ UUIDid.matches(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")Real-world ReDoS attack patterns (all blocked):
// ❌ Classic nested quantifier attack
field.matches(r"(a+)+b")
field.matches(r"(a*)*b")
// ❌ Email ReDoS (similar to CVE-2019-16155)field.matches(r"([a-zA-Z0-9_\-\.]+)+@example\.com")
// ❌ URL ReDoSfield.matches(r"^(https?://)?([a-z0-9-]+\.)*[a-z0-9-]+\.[a-z]{2,}$")// ❌ Quantified alternation
field.matches(r"(a|a)*b")
field.matches(r"(x|xy)+y")
// ❌ Excessive backtrackingfield.matches(r"(a|ab)*c")
field.matches(r"(a*)*b")Descriptive errors help developers understand why patterns are rejected:
ast, _:=env.Compile(`field.matches(r"(a+)+")`)
sql, err:=cel2sql.Convert(ast)
// err: "invalid regex pattern: nested quantifiers detected at position 4"ast, _=env.Compile(`field.matches(r"(a|a)*b")`)
sql, err=cel2sql.Convert(ast)
// err: "invalid regex pattern: quantified alternation detected"ast, _=env.Compile(`field.matches(r"((((((((((a))))))))))")`)
sql, err=cel2sql.Convert(ast)
// err: "invalid regex pattern: pattern exceeds nesting depth limit of 10"Deeply nested CEL expressions that could cause stack overflow or excessive resource consumption:
// ❌ Attack: Deeply nested arithmetic (1000+ levels)
((((((((((x+1) +1) +1) +1) +1) +1)...)))))
// ❌ Attack: Nested boolean conditions
(((((x>0&&x<100) && (x>0&&x<100))...)))))
// ❌ Attack: Nested ternary operators
(((x>0 ? (x>0 ? (x>0 ? 1 : 0) : 0) : 0)...)))cel2sql tracks recursion depth during CEL expression tree traversal:
- Depth Counter: Increments on each
visit()call to AST nodes - Automatic Limit: Default maximum depth of 100 (configurable)
- Fast Rejection: Expressions exceeding limit are rejected immediately
- CWE-674 Protection: Guards against uncontrolled recursion
Implementation:
func (con*converter) visit(expr*exprpb.Expr) error {
con.depth++deferfunc() { con.depth-- }()
ifcon.depth>con.maxDepth {
returnfmt.Errorf("expression exceeds maximum recursion depth of %d", con.maxDepth)
}
// ... convert expression
}The default limit of 100 provides strong protection while supporting realistic expressions:
// Default: Automatic depth limit of 100sql, err:=cel2sql.Convert(ast)
// Custom: Adjust depth limit for specific use casessql, err:=cel2sql.Convert(ast,
cel2sql.WithMaxDepth(200)) // Allow deeper nesting// Conservative: Lower limit for untrusted inputsql, err:=cel2sql.Convert(ast,
cel2sql.WithMaxDepth(50)) // Stricter protectionEvery AST node visit increments the depth counter:
| Expression Type | Approximate Depth per Level |
|---|---|
Simple arithmetic x + 1 | 3-5 nodes |
Nested calls ((x + 1) + 1) | 2-3x per nesting |
Boolean logic a && b | 3-4 nodes |
Ternary a ? b : c | 4-6 nodes |
Function calls int(x) | 2-3 nodes |
Comprehensions list.all(x, ...) | 5-10 nodes |
Example: A CEL expression like ((x + 1) + 1) nested 50 times will consume approximately 150-200 depth units.
Combine depth limits with other protections:
ctx, cancel:=context.WithTimeout(context.Background(), 5*time.Second)
defercancel()
sql, err:=cel2sql.Convert(ast,
cel2sql.WithContext(ctx), // Timeout protectioncel2sql.WithMaxDepth(100), // Recursion protectioncel2sql.WithSchemas(schemas)) // Type safetyiferr!=nil {
// Handle: depth exceeded, timeout, or validation error
}Clear error messages indicate depth limit violations:
_, err:=cel2sql.Convert(deeplyNestedAST)
// err: "expression exceeds maximum recursion depth of 100"_, err:=cel2sql.Convert(deeplyNestedAST, cel2sql.WithMaxDepth(50))
// err: "expression exceeds maximum recursion depth of 50"- Use Default Limits: The default depth of 100 handles realistic expressions
- Validate User Input: Reject obviously malicious patterns before CEL compilation
- Monitor Depth Errors: Log depth limit violations to detect attack patterns
- Combine Protections: Use depth limits WITH context timeouts
- Test Your Limits: Verify legitimate expressions work within configured limits
- CWE-674: Uncontrolled Recursion - Mitigated by automatic depth tracking
- Resource exhaustion from complex expressions
- Long-running conversions blocking your application
- Denial of service via computational complexity
Use Go's context.Context to add timeout and cancellation support:
Implementation:
- Context checking in
cel2sql.go:checkContext() - Checked at key recursion points:
visit()- Main traversal entryvisitCall()- Every function callvisitComprehension()- Before processing comprehensions
import (
"context""time"
)
// With timeoutctx, cancel:=context.WithTimeout(context.Background(), 5*time.Second)
defercancel()
sql, err:=cel2sql.Convert(ast,
cel2sql.WithContext(ctx),
cel2sql.WithSchemas(schemas))
iferr!=nil {
iferrors.Is(err, context.DeadlineExceeded) {
log.Println("Conversion timed out - expression too complex")
} elseiferrors.Is(err, context.Canceled) {
log.Println("Conversion cancelled")
}
}| Use Case | Recommended Timeout | Rationale |
|---|---|---|
| User-provided expressions | 5 seconds | Prevents abuse |
| Admin/trusted expressions | 30 seconds | More complex allowed |
| Simple expressions | 1 second | Fast fail |
| Batch processing | 10 seconds | Balance throughput/safety |
funchandleUserFilter(w http.ResponseWriter, r*http.Request) {
celExpr:=r.FormValue("filter")
// 1. Basic validationiflen(celExpr) >1000 {
http.Error(w, "Expression too long", http.StatusBadRequest)
return
}
// 2. Compile CELast, issues:=env.Compile(celExpr)
ifissues!=nil&&issues.Err() !=nil {
http.Error(w, "Invalid expression", http.StatusBadRequest)
return
}
// 3. Convert with timeoutctx, cancel:=context.WithTimeout(r.Context(), 5*time.Second)
defercancel()
schemas:=provider.GetSchemas()
sql, err:=cel2sql.Convert(ast,
cel2sql.WithContext(ctx),
cel2sql.WithSchemas(schemas))
iferr!=nil {
iferrors.Is(err, context.DeadlineExceeded) {
http.Error(w, "Expression too complex", http.StatusBadRequest)
return
}
http.Error(w, "Conversion failed", http.StatusInternalServerError)
return
}
// 4. Execute query (with timeout)// ...
}Use multiple layers of protection:
funcconvertUserExpression(celExprstring) (string, error) {
// Layer 1: Input validationiflen(celExpr) >1000 {
return"", errors.New("expression too long")
}
ifstrings.Count(celExpr, "(") >20 {
return"", errors.New("too many nested groups")
}
// Layer 2: CEL compilationast, issues:=env.Compile(celExpr)
ifissues!=nil&&issues.Err() !=nil {
return"", fmt.Errorf("compilation failed: %w", issues.Err())
}
// Layer 3: Context timeoutctx, cancel:=context.WithTimeout(context.Background(), 5*time.Second)
defercancel()
// Layer 4: cel2sql conversion (automatic protections)schemas:=provider.GetSchemas()
sql, err:=cel2sql.Convert(ast,
cel2sql.WithContext(ctx),
cel2sql.WithSchemas(schemas))
returnsql, err
}Only expose necessary fields in your schema:
// ✅ Good: Minimal schema with only needed fieldsuserSchema:= pg.Schema{
{Name: "id", Type: "bigint"},
{Name: "name", Type: "text"},
{Name: "email", Type: "text"},
{Name: "active", Type: "boolean"},
{Name: "created_at", Type: "timestamp with time zone"},
}
// ❌ Bad: Exposing sensitive fieldsuserSchema:= pg.Schema{
{Name: "id", Type: "bigint"},
{Name: "name", Type: "text"},
{Name: "password_hash", Type: "text"}, // ❌ Don't expose
{Name: "ssn", Type: "text"}, // ❌ Don't expose
{Name: "credit_card", Type: "text"}, // ❌ Don't expose
{Name: "api_token", Type: "text"}, // ❌ Don't expose
{Name: "internal_notes", Type: "text"}, // ❌ Don't expose
}Always use prepared statements when executing generated SQL:
// ✅ Good: Prepared statementsqlWhere, err:=cel2sql.Convert(ast, cel2sql.WithSchemas(schemas))
iferr!=nil {
returnerr
}
query:="SELECT * FROM users WHERE "+sqlWherestmt, err:=db.Prepare(query)
iferr!=nil {
returnerr
}
deferstmt.Close()
rows, err:=stmt.Query()
// ❌ Bad: String concatenation with user values// (cel2sql generates safe SQL, but this pattern is risky if extended)query:="SELECT * FROM users WHERE "+sqlWhere+" AND extra="+userValueMonitor conversion patterns in production:
import"log/slog"// JSON logging for productionlogger:=slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
sql, err:=cel2sql.Convert(ast,
cel2sql.WithContext(ctx),
cel2sql.WithSchemas(schemas),
cel2sql.WithLogger(logger))
// Logs include:// - JSON path detection decisions// - Comprehension types identified// - Schema lookups// - Regex pattern conversions// - Performance metrics// - Error contextsImplement rate limiting for user-provided expressions:
import"golang.org/x/time/rate"// Per-user rate limiterlimiter:=rate.NewLimiter(rate.Limit(10), 20) // 10 req/sec, burst 20funchandleFilter(w http.ResponseWriter, r*http.Request) {
userID:=getUserID(r)
if!limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// ... convert expression
}Validate user input before CEL compilation:
funcvalidateCELExpression(exprstring) error {
// Length checkiflen(expr) >1000 {
returnerrors.New("expression too long")
}
// Complexity checkifstrings.Count(expr, "(") >20 {
returnerrors.New("too many nested groups")
}
// Character whitelist (optional)for_, r:=rangeexpr {
if!unicode.IsPrint(r) &&!unicode.IsSpace(r) {
returnerrors.New("invalid characters in expression")
}
}
returnnil
}Handle errors securely without leaking information:
sql, err:=cel2sql.Convert(ast, cel2sql.WithSchemas(schemas))
iferr!=nil {
// Log detailed error for debugginglog.Printf("Conversion error: %v", err)
// Return generic error to userreturn"", errors.New("invalid filter expression")
}Test your security assumptions:
funcTestSecurityProtections(t*testing.T) {
tests:= []struct {
namestringexpressionstringshouldErrorbool
}{
{
name: "SQL injection via field name",
expression: `field'; DROP TABLE users--.value == 1`,
shouldError: true,
},
{
name: "ReDoS nested quantifiers",
expression: `field.matches(r"(a+)+")`,
shouldError: true,
},
{
name: "Valid safe expression",
expression: `user.age >= 18 && user.active`,
shouldError: false,
},
}
for_, tt:=rangetests {
t.Run(tt.name, func(t*testing.T) {
ast, issues:=env.Compile(tt.expression)
ifissues!=nil&&issues.Err() !=nil {
if!tt.shouldError {
t.Errorf("Expected success but got error: %v", issues.Err())
}
return
}
_, err:=cel2sql.Convert(ast)
iftt.shouldError&&err==nil {
t.Errorf("Expected error but got success")
} elseif!tt.shouldError&&err!=nil {
t.Errorf("Expected success but got error: %v", err)
}
})
}
}Use this checklist when deploying cel2sql in production:
- Using
WithSchemas()for JSON/JSONB detection - Schemas include only necessary fields
- No sensitive fields (passwords, tokens, SSN, etc.) in schemas
- Context timeout configured (5 seconds recommended)
- Structured logging enabled (with
WithLogger())
- Expression length limited (1000 chars recommended)
- Character whitelist applied if needed
- Complexity checks implemented (nesting depth, etc.)
- Rate limiting implemented for user-provided expressions
- Detailed errors logged for debugging
- Generic errors returned to users (no information leakage)
- Context timeout errors handled gracefully
- Conversion errors don't cause panic
- Using prepared statements for query execution
- Database user has minimal required permissions
- Monitoring/alerting configured for errors
- Security tests included in CI/CD pipeline
- Documentation updated with security considerations
- SQL injection tests (field names, JSON fields)
- ReDoS pattern tests (nested quantifiers, etc.)
- Context timeout tests
- Edge case tests (long expressions, deep nesting)
- Integration tests with real PostgreSQL database
- Conversion errors tracked
- Timeout frequency monitored
- Pattern complexity tracked (via logging)
- Performance metrics collected
- Security alerts configured
- Getting Started Guide - Basic usage and setup
- JSON/JSONB Support - JSON security details
- Regex Matching - ReDoS protection details
- Operators Reference - Operator security
To report security vulnerabilities, please open an issue on GitHub with the "security" label.