Skip to content

Commit 4dc3765

Browse files
authored
fix(api): add security hardening for Attack Paths custom query endpoint (#10238)
1 parent e0d61ba commit 4dc3765

File tree

10 files changed

+482
-25
lines changed

10 files changed

+482
-25
lines changed

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ All notable changes to the **Prowler API** are documented in this file.
1515

1616
### 🐞 Fixed
1717

18+
- Attack Paths: Security hardening for custom query endpoint (Cypher blocklist, input validation, rate limiting, Helm lockdown) [(#10238)](https://github.com/prowler-cloud/prowler/pull/10238)
1819
- Attack Paths: Add missing logging for query execution and exception details in scan error handling [(#10269)](https://github.com/prowler-cloud/prowler/pull/10269)
1920
- Attack Paths: Upgrade Cartography from 0.129.0 to 0.132.0, fixing `exposed_internet` not set on ELB/ELBv2 nodes [(#10272)](https://github.com/prowler-cloud/prowler/pull/10272)
2021

api/src/backend/api/attack_paths/database.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"Neo.ClientError.Statement.AccessMode",
3636
"Neo.ClientError.Procedure.ProcedureNotFound",
3737
]
38+
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
3839

3940
# Module-level process-wide driver singleton
4041
_driver: neo4j.Driver | None = None
@@ -108,13 +109,18 @@ def get_session(
108109
except neo4j.exceptions.Neo4jError as exc:
109110
if (
110111
default_access_mode == neo4j.READ_ACCESS
112+
and exc.code
111113
and exc.code in READ_EXCEPTION_CODES
112114
):
113115
message = "Read query not allowed"
114116
code = READ_EXCEPTION_CODES[0]
115117
raise WriteQueryNotAllowedException(message=message, code=code)
116118

117119
message = exc.message if exc.message is not None else str(exc)
120+
121+
if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
122+
raise ClientStatementException(message=message, code=exc.code)
123+
118124
raise GraphDatabaseQueryException(message=message, code=exc.code)
119125

120126
finally:
@@ -227,3 +233,7 @@ def __str__(self) -> str:
227233

228234
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
229235
pass
236+
237+
238+
class ClientStatementException(GraphDatabaseQueryException):
239+
pass

api/src/backend/api/attack_paths/views_helpers.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import re
23

34
from typing import Any, Iterable
45

@@ -117,6 +118,38 @@ def execute_query(
117118

118119
# Custom query helpers
119120

121+
# Patterns that indicate SSRF or dangerous procedure calls
122+
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
123+
_BLOCKED_PATTERNS = [
124+
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
125+
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
126+
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
127+
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
128+
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
129+
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
130+
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
131+
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
132+
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
133+
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
134+
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
135+
]
136+
137+
# Strip string literals so patterns inside quotes don't cause false positives
138+
# Handles escaped quotes (\' and \") inside strings
139+
_STRING_LITERALS = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
140+
141+
142+
def validate_custom_query(cypher: str) -> None:
143+
"""Reject queries containing known SSRF or dangerous procedure patterns.
144+
145+
Raises ValidationError if a blocked pattern is found.
146+
String literals are stripped before matching to avoid false positives.
147+
"""
148+
stripped = _STRING_LITERALS.sub("", cypher)
149+
for pattern in _BLOCKED_PATTERNS:
150+
if pattern.search(stripped):
151+
raise ValidationError({"query": "Query contains a blocked operation"})
152+
120153

121154
def normalize_custom_query_payload(raw_data):
122155
if not isinstance(raw_data, dict):
@@ -135,6 +168,8 @@ def execute_custom_query(
135168
cypher: str,
136169
provider_id: str,
137170
) -> dict[str, Any]:
171+
validate_custom_query(cypher)
172+
138173
try:
139174
graph = graph_database.execute_read_query(
140175
database=database_name,
@@ -143,6 +178,9 @@ def execute_custom_query(
143178
serialized = _serialize_graph(graph, provider_id)
144179
return _truncate_graph(serialized)
145180

181+
except graph_database.ClientStatementException as exc:
182+
raise ValidationError({"query": exc.message})
183+
146184
except graph_database.WriteQueryNotAllowedException:
147185
raise PermissionDenied(
148186
"Attack Paths query execution failed: read-only queries are enforced"
@@ -227,6 +265,12 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
227265
},
228266
)
229267

268+
filtered_count = len(graph.nodes) - len(nodes)
269+
if filtered_count > 0:
270+
logger.debug(
271+
f"Filtered {filtered_count} nodes without matching provider_id={provider_id}"
272+
)
273+
230274
relationships = []
231275
for relationship in graph.relationships:
232276
if relationship._properties.get("provider_id") != provider_id:

api/src/backend/api/tests/test_attack_paths.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,72 @@ def test_execute_custom_query_wraps_graph_errors():
501501
mock_logger.error.assert_called_once()
502502

503503

504+
# -- validate_custom_query ------------------------------------------------
505+
506+
507+
@pytest.mark.parametrize(
508+
"cypher",
509+
[
510+
"LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x",
511+
"load csv from 'http://evil.com' as row return row",
512+
"CALL apoc.load.json('http://evil.com/') YIELD value RETURN value",
513+
"CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list",
514+
"CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node",
515+
"CALL apoc.export.csv.all('file.csv', {})",
516+
"CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
517+
"CALL apoc.systemdb.graph() YIELD nodes RETURN nodes",
518+
"CALL apoc.config.list() YIELD key, value RETURN key, value",
519+
"CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})",
520+
"CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value",
521+
"CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})",
522+
"CALL apoc.custom.asProcedure('myProc', 'RETURN 1')",
523+
],
524+
ids=[
525+
"LOAD_CSV",
526+
"LOAD_CSV_lowercase",
527+
"apoc.load.json",
528+
"apoc.load.csvParams",
529+
"apoc.import.csv",
530+
"apoc.export.csv",
531+
"apoc.cypher.run",
532+
"apoc.systemdb.graph",
533+
"apoc.config.list",
534+
"apoc.periodic.iterate",
535+
"apoc.do.when",
536+
"apoc.trigger.add",
537+
"apoc.custom.asProcedure",
538+
],
539+
)
540+
def test_validate_custom_query_rejects_blocked_patterns(cypher):
541+
with pytest.raises(ValidationError) as exc:
542+
views_helpers.validate_custom_query(cypher)
543+
544+
assert "blocked operation" in str(exc.value.detail)
545+
546+
547+
@pytest.mark.parametrize(
548+
"cypher",
549+
[
550+
"MATCH (n:AWSAccount) RETURN n LIMIT 10",
551+
"MATCH (a)-[r]->(b) RETURN a, r, b",
552+
"MATCH (n) WHERE n.name CONTAINS 'load' RETURN n",
553+
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
554+
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
555+
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
556+
],
557+
ids=[
558+
"simple_match",
559+
"traversal",
560+
"contains_load_substring",
561+
"apoc_virtual_node",
562+
"apoc_load_inside_single_quotes",
563+
"load_csv_inside_double_quotes",
564+
],
565+
)
566+
def test_validate_custom_query_allows_clean_queries(cypher):
567+
views_helpers.validate_custom_query(cypher)
568+
569+
504570
# -- _truncate_graph ----------------------------------------------------------
505571

506572

0 commit comments

Comments
 (0)