Skip to content

Commit b4bd468

Browse files
feat(aap): blocking id (#15042)
## Description - new blocking id feature - libddwaf update (required for this feature) - refactor of blocking configuration with dedicated type - tests updated - will also be validated by system tests DataDog/system-tests#5674 APPSEC-59798
1 parent 216053e commit b4bd468

File tree

46 files changed

+256
-171
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+256
-171
lines changed

.github/workflows/system-tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
persist-credentials: false
4646
repository: 'DataDog/system-tests'
4747
# Automatically managed, use scripts/update-system-tests-version to update
48-
ref: '12860dc3fa4a474907f18f8313518989c80772ce'
48+
ref: '279a4f17c9392157cdc106e627c2b57c2233899b'
4949

5050
- name: Download wheels to binaries directory
5151
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -90,7 +90,7 @@ jobs:
9090
persist-credentials: false
9191
repository: 'DataDog/system-tests'
9292
# Automatically managed, use scripts/update-system-tests-version to update
93-
ref: '12860dc3fa4a474907f18f8313518989c80772ce'
93+
ref: '279a4f17c9392157cdc106e627c2b57c2233899b'
9494

9595
- name: Build runner
9696
uses: ./.github/actions/install_runner
@@ -275,7 +275,7 @@ jobs:
275275
persist-credentials: false
276276
repository: 'DataDog/system-tests'
277277
# Automatically managed, use scripts/update-system-tests-version to update
278-
ref: '12860dc3fa4a474907f18f8313518989c80772ce'
278+
ref: '279a4f17c9392157cdc106e627c2b57c2233899b'
279279
- name: Download wheels to binaries directory
280280
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
281281
with:

.gitlab-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ variables:
1414
DD_VPA_TEMPLATE: "vpa-template-cpu-p70-10percent-2x-oom-min-cap"
1515
# CI_DEBUG_SERVICES: "true"
1616
# Automatically managed, use scripts/update-system-tests-version to update
17-
SYSTEM_TESTS_REF: "12860dc3fa4a474907f18f8313518989c80772ce"
17+
SYSTEM_TESTS_REF: "279a4f17c9392157cdc106e627c2b57c2233899b"
1818

1919
default:
2020
interruptible: true

ddtrace/appsec/_asm_request_context.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ddtrace.appsec._constants import APPSEC
1919
from ddtrace.appsec._constants import SPAN_DATA_NAMES
2020
from ddtrace.appsec._constants import Constant_Class
21+
from ddtrace.appsec._utils import Block_config
2122
from ddtrace.appsec._utils import Telemetry_result
2223
from ddtrace.appsec._utils import get_triggers
2324
from ddtrace.contrib.internal.trace_utils_base import _normalize_tag_name
@@ -104,7 +105,7 @@ def __init__(self, span: Optional[Span] = None, rc_products: str = ""):
104105
self.telemetry: Telemetry_result = Telemetry_result()
105106
self.addresses_sent: Set[str] = set()
106107
self.waf_triggers: List[Dict[str, Any]] = []
107-
self.blocked: Optional[Dict[str, Any]] = None
108+
self.blocked: Optional[Block_config] = None
108109
self.finalized: bool = False
109110
self.api_security_reported: int = 0
110111
self.rc_products: str = rc_products
@@ -126,11 +127,11 @@ def is_blocked() -> bool:
126127
return env.blocked is not None
127128

128129

129-
def get_blocked() -> Dict[str, Any]:
130+
def get_blocked() -> Optional[Block_config]:
130131
env = _get_asm_context()
131132
if env is None:
132-
return {}
133-
return env.blocked or {}
133+
return None
134+
return env.blocked or None
134135

135136

136137
def get_entry_span() -> Optional[Span]:
@@ -200,15 +201,11 @@ def _use_html(headers) -> bool:
200201

201202
def _ctype_from_headers(block_config, headers) -> None:
202203
"""compute MIME type of the blocked response and store it in the block config"""
203-
desired_type = block_config.get("type", "auto")
204-
if desired_type == "auto":
205-
block_config["content-type"] = "text/html" if _use_html(headers) else "application/json"
206-
else:
207-
block_config["content-type"] = "text/html" if block_config["type"] == "html" else "application/json"
204+
if (block_config.type == "auto" and _use_html(headers)) or block_config.type == "html":
205+
block_config.content_type = "text/html"
208206

209207

210-
def set_blocked(blocked: Dict[str, Any]) -> None:
211-
blocked = blocked.copy()
208+
def set_blocked(blocked: Block_config) -> None:
212209
env = _get_asm_context()
213210
if env is None:
214211
logger.warning(WARNING_TAGS.SET_BLOCKED_NO_ASM_CONTEXT, extra=log_extra, stack_info=True)
@@ -217,6 +214,16 @@ def set_blocked(blocked: Dict[str, Any]) -> None:
217214
env.blocked = blocked
218215

219216

217+
def set_blocked_dict(block: Union[Dict[str, Any], Block_config, None]) -> None:
218+
if isinstance(block, dict):
219+
blocked = Block_config(**block)
220+
elif block is None:
221+
blocked = Block_config()
222+
else:
223+
blocked = block
224+
set_blocked(blocked)
225+
226+
220227
def update_span_metrics(span: Span, name: str, value: Union[float, int]) -> None:
221228
span.set_metric(name, value + (span.get_metric(name) or 0.0))
222229

@@ -713,7 +720,7 @@ def asm_listen():
713720
core.on("asgi.start_response", _call_waf)
714721
core.on("asgi.finalize_response", _set_headers_and_response)
715722

716-
core.on("asm.set_blocked", set_blocked)
723+
core.on("asm.set_blocked", set_blocked_dict)
717724
core.on("asm.get_blocked", get_blocked, "block_config")
718725

719726
core.on("context.ended.wsgi.__call__", _on_context_ended)

ddtrace/appsec/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class APPSEC(metaclass=Constant_Class):
125125
ERROR_MESSAGE: Literal["_dd.appsec.error.message"] = "_dd.appsec.error.message"
126126
UNSUPPORTED_EVENT_TYPE: Literal["_dd.appsec.unsupported_event_type"] = "_dd.appsec.unsupported_event_type"
127127
SERVERLESS_TRACER_ENABLED: Literal["_dd.appsec.serverless.tracer"] = "_dd.appsec.serverless.tracer"
128+
SECURITY_RESPONSE_ID: Literal["[security_response_id]"] = "[security_response_id]"
128129

129130

130131
TELEMETRY_OFF_NAME = "OFF"

ddtrace/appsec/_handlers.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import json
33
from typing import Any
44
from typing import Dict
5+
from typing import List
56
from typing import Optional
7+
from typing import Tuple
68
from typing import Union
79

810
from ddtrace._trace.span import Span
@@ -15,6 +17,7 @@
1517
from ddtrace.appsec._http_utils import extract_cookies_from_headers
1618
from ddtrace.appsec._http_utils import normalize_headers
1719
from ddtrace.appsec._http_utils import parse_http_body
20+
from ddtrace.appsec._utils import Block_config
1821
from ddtrace.contrib import trace_utils
1922
from ddtrace.contrib.internal.trace_utils_base import _get_request_header_user_agent
2023
from ddtrace.contrib.internal.trace_utils_base import _set_url_tag
@@ -292,24 +295,23 @@ def _on_grpc_server_data(headers, request_message, method, metadata):
292295
set_waf_address(SPAN_DATA_NAMES.GRPC_SERVER_REQUEST_METADATA, dict(metadata))
293296

294297

295-
def _wsgi_make_block_content(ctx, construct_url):
298+
def _wsgi_make_block_content(ctx, construct_url) -> Tuple[int, List[Tuple[str, str]], bytes]:
296299
middleware = ctx.get_item("middleware")
297300
req_span = ctx.get_item("req_span")
298301
headers = ctx.get_item("headers")
299302
environ = ctx.get_item("environ")
300303
if req_span is None:
301304
raise ValueError("request span not found")
302-
block_config = get_blocked()
303-
desired_type = block_config.get("type", "auto")
305+
block_config: Block_config = get_blocked() or Block_config()
304306
ctype = None
305-
if desired_type == "none":
306-
content = ""
307-
resp_headers = [("content-type", "text/plain; charset=utf-8"), ("location", block_config.get("location", ""))]
307+
if block_config.type == "none":
308+
content = b""
309+
resp_headers = [("content-type", "text/plain; charset=utf-8"), ("location", block_config.location)]
308310
else:
309-
ctype = block_config.get("content-type", "application/json")
310-
content = http_utils._get_blocked_template(ctype).encode("UTF-8")
311+
ctype = block_config.content_type
312+
content = http_utils._get_blocked_template(ctype, block_config.block_id).encode("UTF-8")
311313
resp_headers = [("content-type", ctype)]
312-
status = block_config.get("status_code", 403)
314+
status = block_config.status_code
313315
try:
314316
req_span._set_tag_str(RESPONSE_HEADERS + ".content-length", str(len(content)))
315317
if ctype is not None:
@@ -332,28 +334,26 @@ def _wsgi_make_block_content(ctx, construct_url):
332334
return status, resp_headers, content
333335

334336

335-
def _asgi_make_block_content(ctx, url):
337+
def _asgi_make_block_content(ctx, url) -> Tuple[int, List[Tuple[bytes, bytes]], bytes]:
336338
middleware = ctx.get_item("middleware")
337339
req_span = ctx.get_item("req_span")
338340
headers = ctx.get_item("headers")
339341
environ = ctx.get_item("environ")
340342
if req_span is None:
341343
raise ValueError("request span not found")
342-
block_config = get_blocked()
343-
desired_type = block_config.get("type", "auto")
344+
block_config = get_blocked() or Block_config()
344345
ctype = None
345-
if desired_type == "none":
346-
content = ""
346+
if block_config.type == "none":
347+
content = b""
347348
resp_headers = [
348349
(b"content-type", b"text/plain; charset=utf-8"),
349-
(b"location", block_config.get("location", "").encode()),
350+
(b"location", block_config.location.encode()),
350351
]
351352
else:
352-
ctype = block_config.get("content-type", "application/json")
353-
content = http_utils._get_blocked_template(ctype).encode("UTF-8")
353+
content = http_utils._get_blocked_template(block_config.content_type, block_config.block_id).encode("UTF-8")
354354
# ctype = f"{ctype}; charset=utf-8" can be considered at some point
355-
resp_headers = [(b"content-type", ctype.encode())]
356-
status = block_config.get("status_code", 403)
355+
resp_headers = [(b"content-type", block_config.content_type.encode())]
356+
status = block_config.status_code
357357
try:
358358
req_span._set_tag_str(RESPONSE_HEADERS + ".content-length", str(len(content)))
359359
if ctype is not None:

ddtrace/appsec/_processor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from ddtrace.appsec._exploit_prevention.stack_traces import report_stack
3636
from ddtrace.appsec._trace_utils import _asm_manual_keep
3737
from ddtrace.appsec._utils import Binding_error
38+
from ddtrace.appsec._utils import Block_config
3839
from ddtrace.appsec._utils import DDWaf_result
3940
from ddtrace.constants import _ORIGIN_KEY
4041
from ddtrace.constants import _RUNTIME_FAMILY
@@ -341,7 +342,7 @@ def _waf_action(
341342
log.debug("[DDAS-011-00] ASM In-App WAF returned: %s. Timeout %s", waf_results.data, waf_results.timeout)
342343

343344
if blocked:
344-
_asm_request_context.set_blocked(blocked)
345+
_asm_request_context.set_blocked(Block_config(**blocked))
345346

346347
allowed = True
347348
if waf_results.keep:

ddtrace/appsec/_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,26 @@ def __init__(self) -> None:
154154
self.durations: Dict[str, float] = collections.defaultdict(float)
155155

156156

157+
class Block_config:
158+
__slots__ = ["block_id", "grpc_status_code", "status_code", "type", "content_type", "location"]
159+
160+
def __init__(
161+
self,
162+
type: str = "auto", # noqa: A002
163+
status_code: int = 403,
164+
grpc_status_code: int = 10,
165+
security_response_id: str = "default",
166+
location: str = "",
167+
**_kwargs,
168+
) -> None:
169+
self.block_id: str = security_response_id
170+
self.grpc_status_code: int = grpc_status_code
171+
self.status_code: int = status_code
172+
self.type: str = type
173+
self.location = location.replace(APPSEC.SECURITY_RESPONSE_ID, security_response_id)
174+
self.content_type: str = "application/json"
175+
176+
157177
class Telemetry_result:
158178
__slots__ = [
159179
"blocked",

ddtrace/contrib/internal/django/response.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ddtrace.internal.logger import get_logger
3030
from ddtrace.internal.schema import schematize_url_operation
3131
from ddtrace.internal.schema.span_attribute_schema import SpanDirection
32+
from ddtrace.internal.utils import Block_config
3233
from ddtrace.internal.utils import get_argument_value
3334
from ddtrace.internal.utils import get_blocked
3435
from ddtrace.internal.utils import http as http_utils
@@ -119,27 +120,24 @@ def traced_get_response(func: FunctionType, args: Tuple[Any, ...], kwargs: Dict[
119120

120121
response = None
121122

122-
def blocked_response():
123-
block_config = get_blocked() or {}
124-
desired_type = block_config.get("type", "auto")
125-
status = block_config.get("status_code", 403)
126-
if desired_type == "none":
127-
response = HttpResponse("", status=status)
128-
location = block_config.get("location", "")
129-
if location:
130-
response["location"] = location
123+
def blocked_response(block_config: Block_config):
124+
if block_config.type == "none":
125+
response = HttpResponse("", status=block_config.status_code)
126+
if block_config.location:
127+
response["location"] = block_config.location
131128
else:
132-
ctype = block_config.get("content-type", "application/json")
133-
content = http_utils._get_blocked_template(ctype)
134-
response = HttpResponse(content, content_type=ctype, status=status)
129+
content = http_utils._get_blocked_template(block_config.content_type, block_config.block_id)
130+
response = HttpResponse(
131+
content, content_type=block_config.content_type, status=block_config.status_code
132+
)
135133
response.content = content
136134
response["Content-Length"] = len(content.encode())
137135
utils._after_request_tags(pin, ctx.span, request, response)
138136
return response
139137

140138
try:
141-
if get_blocked():
142-
response = blocked_response()
139+
if block_config := get_blocked():
140+
response = blocked_response(block_config)
143141
else:
144142
query = request.META.get("QUERY_STRING", "")
145143
uri = utils.get_request_uri(request)
@@ -158,25 +156,25 @@ def blocked_response():
158156
)
159157
core.dispatch("django.start_response.post", ("Django",))
160158

161-
if get_blocked():
162-
response = blocked_response()
159+
if block_config := get_blocked():
160+
response = blocked_response(block_config)
163161
else:
164162
try:
165163
response = func(*args, **kwargs)
166164
except BlockingException as e:
167165
set_blocked(e.args[0])
168-
response = blocked_response()
166+
response = blocked_response(e.args[0])
169167
return response
170168

171-
if get_blocked():
172-
response = blocked_response()
169+
if block_config := get_blocked():
170+
response = blocked_response(block_config)
173171

174172
finally:
175173
core.dispatch("django.finalize_response.pre", (ctx, utils._after_request_tags, request, response))
176174
if not get_blocked():
177175
core.dispatch("django.finalize_response", ("Django",))
178-
if get_blocked():
179-
response = blocked_response()
176+
if block_config := get_blocked():
177+
response = blocked_response(block_config)
180178
return response
181179

182180

ddtrace/contrib/internal/flask/patch.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _wrapped_start_response(self, start_response, ctx, status_code, headers, exc
125125
core.dispatch("flask.start_response.pre", (flask.request, ctx, config.flask, status_code, headers))
126126
if not get_blocked():
127127
core.dispatch("flask.start_response", ("Flask",))
128-
if get_blocked():
128+
if block_config := get_blocked():
129129
# response code must be set here, or it will be too late
130130
result_content = core.dispatch_with_results( # ast-grep-ignore: core-dispatch-with-results
131131
"flask.block.request.content", ()
@@ -134,16 +134,13 @@ def _wrapped_start_response(self, start_response, ctx, status_code, headers, exc
134134
_, status, response_headers = result_content.value
135135
result = start_response(str(status), response_headers)
136136
else:
137-
block_config = get_blocked()
138-
desired_type = block_config.get("type", "auto")
139-
status = block_config.get("status_code", 403)
140-
if desired_type == "none":
141-
response_headers = []
142-
else:
143-
ctype = block_config.get("content-type", "application/json")
144-
response_headers = [("content-type", ctype)]
145-
result = start_response(str(status), response_headers)
146-
core.dispatch("flask.start_response.blocked", (ctx, config.flask, response_headers, status))
137+
response_headers = (
138+
[] if block_config.type == "none" else [("content-type", block_config.content_type)]
139+
)
140+
result = start_response(str(block_config.status_code), response_headers)
141+
core.dispatch(
142+
"flask.start_response.blocked", (ctx, config.flask, response_headers, block_config.status_code)
143+
)
147144
else:
148145
result = start_response(status_code, headers)
149146
else:
@@ -549,8 +546,10 @@ def _wrap(code_or_exception, f):
549546
def _block_request_callable(call):
550547
set_blocked()
551548
core.dispatch("flask.blocked_request_callable", (call,))
552-
ctype = get_blocked().get("content-type", "application/json")
553-
abort(flask.Response(http_utils._get_blocked_template(ctype), content_type=ctype, status=403))
549+
block_config = get_blocked()
550+
ctype = block_config.content_type if block_config else "application/json"
551+
block_id = block_config.block_id if block_config else "(default)"
552+
abort(flask.Response(http_utils._get_blocked_template(ctype, block_id), content_type=ctype, status=403))
554553

555554

556555
def request_patcher(name):

0 commit comments

Comments
 (0)