Skip to content

Commit 545a719

Browse files
committed
fix: validate search field names against mapping allowlist
Prevent authenticated SQL injection in call-data search by rejecting unsanitised field identifiers before they are concatenated into WHERE clauses. Bump version to 1.5.16.
1 parent 0bfe593 commit 545a719

4 files changed

Lines changed: 196 additions & 1 deletion

File tree

data/service/search.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func buildQuery(elems []interface{}, orLogic bool, mappingJSON json.RawMessage,
122122
sLimit = 200
123123

124124
smartMap := make(map[string]model.MappingSmart)
125+
fieldAllowlist := buildSearchFieldAllowlist(mappingJSON)
125126

126127
firsLoop := true
127128

@@ -132,6 +133,11 @@ func buildQuery(elems []interface{}, orLogic bool, mappingJSON json.RawMessage,
132133
formName := mapData["name"].(string)
133134
formType := mapData["type"].(string)
134135

136+
if formName != "smartinput" && formName != "limit" && !validateSearchFieldName(formName, fieldAllowlist) {
137+
logger.Error("Rejected search field name: ", formName)
138+
continue
139+
}
140+
135141
//We should be sure that this is value string
136142
switch x := formVal.(type) {
137143
case string:
@@ -185,6 +191,11 @@ func buildQuery(elems []interface{}, orLogic bool, mappingJSON json.RawMessage,
185191
typeValue = modSmart.Type
186192
}
187193

194+
if !validateSearchFieldName(operandField, fieldAllowlist) {
195+
logger.Error("Rejected smartinput field name: ", operandField)
196+
continue
197+
}
198+
188199
if strings.Contains(operandField, ".") {
189200
elemArray := strings.Split(operandField, ".")
190201
if typeValue == "integer" {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/Jeffail/gabs/v2"
9+
"github.com/sipcapture/homer-app/utils/logger"
10+
)
11+
12+
var (
13+
searchJSONBColumns = map[string]struct{}{
14+
"data_header": {},
15+
"protocol_header": {},
16+
}
17+
18+
safeJSONKeyPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
19+
)
20+
21+
func buildSearchFieldAllowlist(mappingJSON json.RawMessage) map[string]struct{} {
22+
allowlist := make(map[string]struct{})
23+
24+
for _, field := range []string{"limit", "smartinput", "raw", "id", "sid", "create_date"} {
25+
allowlist[field] = struct{}{}
26+
}
27+
28+
if len(mappingJSON) == 0 {
29+
return allowlist
30+
}
31+
32+
sMapping, err := gabs.ParseJSON(mappingJSON)
33+
if err != nil {
34+
logger.Error("buildSearchFieldAllowlist: invalid mapping JSON: ", err)
35+
return allowlist
36+
}
37+
38+
for _, val := range sMapping.Children() {
39+
if !val.Exists("id") {
40+
continue
41+
}
42+
id, ok := val.S("id").Data().(string)
43+
if !ok || id == "" {
44+
continue
45+
}
46+
allowlist[id] = struct{}{}
47+
}
48+
49+
return allowlist
50+
}
51+
52+
func validateSearchFieldName(formName string, allowlist map[string]struct{}) bool {
53+
if formName == "" {
54+
return false
55+
}
56+
57+
if _, ok := allowlist[formName]; !ok {
58+
return false
59+
}
60+
61+
if !strings.Contains(formName, ".") {
62+
return true
63+
}
64+
65+
parts := strings.Split(formName, ".")
66+
if len(parts) != 2 {
67+
return false
68+
}
69+
70+
if _, ok := searchJSONBColumns[parts[0]]; !ok {
71+
return false
72+
}
73+
74+
return safeJSONKeyPattern.MatchString(parts[1])
75+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package service
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestValidateSearchFieldName_rejectsSQLInjection(t *testing.T) {
10+
allowlist := buildSearchFieldAllowlist(json.RawMessage(`[
11+
{"id":"data_header.callid","type":"string"},
12+
{"id":"protocol_header.srcIp","type":"string"},
13+
{"id":"sid","type":"string"},
14+
{"id":"raw","type":"string"}
15+
]`))
16+
17+
cases := []struct {
18+
name string
19+
field string
20+
want bool
21+
}{
22+
{name: "allowed dotted field", field: "data_header.callid", want: true},
23+
{name: "allowed column", field: "sid", want: true},
24+
{name: "allowed raw", field: "raw", want: true},
25+
{name: "injection in identifier", field: "1=1) OR (SELECT pg_sleep(5)) IN (", want: false},
26+
{name: "unknown field", field: "evil_column", want: false},
27+
{name: "jsonb escape attempt", field: "data_header.callid' OR 1=1--", want: false},
28+
{name: "extra path segment", field: "data_header.callid.extra", want: false},
29+
{name: "invalid jsonb column", field: "users.password", want: false},
30+
}
31+
32+
for _, tc := range cases {
33+
t.Run(tc.name, func(t *testing.T) {
34+
if got := validateSearchFieldName(tc.field, allowlist); got != tc.want {
35+
t.Fatalf("validateSearchFieldName(%q) = %v, want %v", tc.field, got, tc.want)
36+
}
37+
})
38+
}
39+
}
40+
41+
func TestBuildQuery_rejectsInjectedFieldName(t *testing.T) {
42+
mappingJSON := json.RawMessage(`[
43+
{"id":"data_header.callid","type":"string"},
44+
{"id":"sid","type":"string"}
45+
]`)
46+
47+
elems := []interface{}{
48+
map[string]interface{}{
49+
"name": "1=1) OR (SELECT 1) IN (",
50+
"value": "x",
51+
"type": "string",
52+
},
53+
}
54+
55+
sql, _, values := buildQuery(elems, false, mappingJSON, 0)
56+
if sql != "" {
57+
t.Fatalf("expected empty SQL for rejected field, got %q", sql)
58+
}
59+
if len(values) != 0 {
60+
t.Fatalf("expected no bind values, got %v", values)
61+
}
62+
}
63+
64+
func TestBuildQuery_rejectsInjectedSmartinputField(t *testing.T) {
65+
mappingJSON := json.RawMessage(`[
66+
{"id":"data_header.callid","type":"string"}
67+
]`)
68+
69+
elems := []interface{}{
70+
map[string]interface{}{
71+
"name": "smartinput",
72+
"value": "1=1) OR (SELECT 1) IN ( = x",
73+
"type": "string",
74+
},
75+
}
76+
77+
sql, _, values := buildQuery(elems, false, mappingJSON, 0)
78+
if sql != "" {
79+
t.Fatalf("expected empty SQL for rejected smartinput field, got %q", sql)
80+
}
81+
if len(values) != 0 {
82+
t.Fatalf("expected no bind values, got %v", values)
83+
}
84+
}
85+
86+
func TestBuildQuery_allowsMappedField(t *testing.T) {
87+
mappingJSON := json.RawMessage(`[
88+
{"id":"data_header.callid","type":"string"}
89+
]`)
90+
91+
elems := []interface{}{
92+
map[string]interface{}{
93+
"name": "data_header.callid",
94+
"value": "abc@example.com",
95+
"type": "string",
96+
},
97+
}
98+
99+
sql, _, values := buildQuery(elems, false, mappingJSON, 0)
100+
if sql == "" {
101+
t.Fatal("expected SQL fragment for allowed field")
102+
}
103+
if !strings.Contains(sql, "data_header->>'callid'") {
104+
t.Fatalf("unexpected SQL: %q", sql)
105+
}
106+
if len(values) != 1 || values[0] != "abc@example.com" {
107+
t.Fatalf("unexpected bind values: %v", values)
108+
}
109+
}

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package main
22

33
// VERSION
4-
var VERSION_APPLICATION = "1.5.15"
4+
var VERSION_APPLICATION = "1.5.16"
55

66
// NAME
77
var NAME_APPLICATION = "homer-app"

0 commit comments

Comments
 (0)