-
-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Expand file tree
/
Copy pathtest_openapi.py
More file actions
456 lines (400 loc) · 27.7 KB
/
Copy pathtest_openapi.py
File metadata and controls
456 lines (400 loc) · 27.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit coverage for the OpenAPI/Swagger target extractor (lib/parse/openapi.py): schema example
synthesis, $ref resolution (incl. cycles), base-URL resolution (v2 + v3, relative/templated servers),
request-body handling (JSON / form), parameter->PLACE mapping, and (importantly) graceful handling of
malformed / poorly-defined specifications (a broken spec must never crash or hang the parser).
stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
"""
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.parse.openapi import openApiTargets, yaml as _yaml
HAS_YAML = _yaml is not None
def _targets(spec, origin="http://h"):
return openApiTargets(json.dumps(spec) if isinstance(spec, dict) else spec, origin)
def _byMethodPath(targets):
return dict(("%s %s" % (method, url), (method, url, data, headers)) for url, method, data, headers in targets)
class TestOpenApi(unittest.TestCase):
def test_v3_query_path_and_base(self):
spec = {"openapi": "3.0.0", "servers": [{"url": "/api"}],
"paths": {"/pet/{id}": {"get": {"parameters": [
{"name": "id", "in": "path", "schema": {"type": "integer"}},
{"name": "q", "in": "query", "schema": {"type": "string", "example": "x"}}]}}}}
targets = _targets(spec, "http://host:8080")
self.assertEqual(len(targets), 1)
url, method, data, headers = targets[0]
self.assertEqual(method, "GET")
from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK
self.assertEqual(url, "http://host:8080/api/pet/1%s?q=x" % MARK) # relative server + filled+marked path + query
self.assertIsNone(data)
def test_v3_json_body_sets_data_and_content_type(self):
spec = {"openapi": "3.0.0", "paths": {"/o": {"post": {"requestBody": {"content": {"application/json":
{"schema": {"type": "object", "properties": {"name": {"type": "string"}, "qty": {"type": "integer"}}}}}}}}}}
url, method, data, headers = _targets(spec)[0]
self.assertEqual(method, "POST")
self.assertEqual(json.loads(data), {"name": "1", "qty": 1})
self.assertIn(("Content-Type", "application/json"), headers)
def test_form_urlencoded_body(self):
spec = {"openapi": "3.0.0", "paths": {"/login": {"post": {"requestBody": {"content":
{"application/x-www-form-urlencoded": {"schema": {"type": "object",
"properties": {"u": {"type": "string"}, "p": {"type": "string"}}}}}}}}}}
url, method, data, headers = _targets(spec)[0]
self.assertEqual(sorted(data.split("&")), ["p=1", "u=1"])
def test_value_synthesis(self):
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "a", "in": "query", "schema": {"type": "integer"}},
{"name": "b", "in": "query", "schema": {"type": "boolean"}},
{"name": "c", "in": "query", "schema": {"type": "string", "enum": ["first", "second"]}},
{"name": "d", "in": "query", "schema": {"type": "string", "default": "dd"}},
{"name": "e", "in": "query", "schema": {"type": "string", "format": "uuid"}}]}}}}
url = _targets(spec)[0][0]
self.assertIn("a=1", url)
self.assertIn("b=true", url)
self.assertIn("c=first", url) # enum[0]
self.assertIn("d=dd", url) # default
self.assertIn("e=11111111-1111-1111-1111-111111111111", url) # format uuid
def test_ref_resolution_and_allof_oneof(self):
spec = {"openapi": "3.0.0",
"components": {"schemas": {"Tag": {"type": "object", "properties": {"n": {"type": "string"}}}}},
"paths": {
"/ref": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tag"}}}}}},
"/all": {"post": {"requestBody": {"content": {"application/json": {"schema": {"allOf": [
{"type": "object", "properties": {"x": {"type": "string"}}},
{"type": "object", "properties": {"y": {"type": "integer"}}}]}}}}}},
"/one": {"post": {"requestBody": {"content": {"application/json": {"schema": {"oneOf": [
{"type": "object", "properties": {"only": {"type": "string"}}},
{"type": "object", "properties": {"other": {"type": "string"}}}]}}}}}}}}
m = _byMethodPath(_targets(spec))
self.assertEqual(json.loads(m["POST http://h/ref"][2]), {"n": "1"})
self.assertEqual(json.loads(m["POST http://h/all"][2]), {"x": "1", "y": 1}) # allOf merged
self.assertEqual(json.loads(m["POST http://h/one"][2]), {"only": "1"}) # oneOf -> first
def test_ref_cycle_terminates(self):
spec = {"openapi": "3.0.0",
"components": {"schemas": {"Node": {"type": "object", "properties": {
"name": {"type": "string"}, "parent": {"$ref": "#/components/schemas/Node"}}}}},
"paths": {"/n": {"post": {"requestBody": {"content": {"application/json":
{"schema": {"$ref": "#/components/schemas/Node"}}}}}}}}
targets = _targets(spec) # must not hang / recurse forever
self.assertEqual(len(targets), 1)
self.assertTrue(json.loads(targets[0][2]).get("name") == "1")
def test_swagger_v2_base_and_body(self):
spec = {"swagger": "2.0", "host": "api.example.com", "basePath": "/v2", "schemes": ["https"],
"paths": {"/pet": {"post": {"parameters": [{"name": "b", "in": "body",
"schema": {"type": "object", "properties": {"id": {"type": "integer"}}}}]}}}}
url, method, data, headers = _targets(spec, None)[0]
self.assertEqual(url, "https://api.example.com/v2/pet")
self.assertEqual(json.loads(data), {"id": 1})
def test_server_template_variables(self):
spec = {"openapi": "3.0.0", "servers": [{"url": "https://{env}.x.io/{ver}",
"variables": {"env": {"default": "prod"}, "ver": {"default": "v3"}}}],
"paths": {"/p": {"get": {}}}}
self.assertEqual(_targets(spec, None)[0][0], "https://prod.x.io/v3/p")
def test_headers_are_hashable_tuples(self):
# kb.targets is an OrderedSet, so the emitted headers must be hashable (tuple, not list)
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "h", "in": "header", "schema": {"type": "string"}}]}}}}
headers = _targets(spec)[0][3]
self.assertTrue(headers is None or isinstance(tuple(headers), tuple))
def test_header_and_cookie_params_are_injection_marked(self):
# header/cookie params get the custom injection mark ('*') appended so they become testable
# (custom) injection points (query/body params are still auto-tested alongside them)
from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "X-Api", "in": "header", "schema": {"type": "string", "example": "k"}},
{"name": "sess", "in": "cookie", "schema": {"type": "string", "example": "v"}}]}}}}
headers = dict(_targets(spec)[0][3])
self.assertEqual(headers["X-Api"], "k" + MARK)
self.assertEqual(headers["Cookie"], "sess=v" + MARK)
# --- graceful degradation: a broken/poorly-defined spec must never crash the parser ---
def test_malformed_raises_valueerror(self):
for bad in ("{not json,,,", "[1,2,3]", "{}", '{"openapi":"3.0.0"}', '{"openapi":"3.0.0","paths":[1,2]}'):
self.assertRaises(ValueError, openApiTargets, bad, "http://h")
def test_malformed_servers_do_not_crash(self):
for servers in ('{"url":"/a"}', '"http://h"', "[]"):
spec = '{"openapi":"3.0.0","servers":%s,"paths":{"/x":{"get":{}}}}' % servers
self.assertEqual(len(openApiTargets(spec, "http://h")), 1) # no crash, still one target
def test_url_and_body_values_are_encoded(self):
# special characters in synthesized values must be percent-encoded so they can not break the
# URL structure (param smuggling) or the form body
spec = {"openapi": "3.0.0", "paths": {
"/x/{p}": {"get": {"parameters": [
{"name": "p", "in": "path", "schema": {"type": "string", "example": "a/b"}},
{"name": "q", "in": "query", "schema": {"type": "string", "example": "a b&c=d"}}]}},
"/f": {"post": {"requestBody": {"content": {"application/x-www-form-urlencoded":
{"schema": {"type": "object", "properties": {"u": {"type": "string", "example": "a b&x"}}}}}}}}}}
byMethod = dict((method, (url, data)) for url, method, data, headers in _targets(spec))
getUrl = byMethod["GET"][0]
self.assertIn("/x/a%2Fb", getUrl) # path value '/' encoded (no extra segment)
self.assertIn("q=a%20b%26c%3Dd", getUrl) # query value space/&/= encoded (no smuggling)
self.assertNotIn(" ", getUrl)
self.assertEqual(byMethod["POST"][1], "u=a%20b%26x")
@unittest.skipUnless(HAS_YAML, "pyyaml not available")
def test_yaml_spec(self):
y = ("openapi: 3.0.0\n"
"paths:\n"
" /y:\n"
" get:\n"
" parameters:\n"
" - name: q\n"
" in: query\n"
" schema: {type: string, example: hi}\n")
targets = openApiTargets(y, "http://h")
self.assertEqual(len(targets), 1)
self.assertEqual(targets[0][0], "http://h/y?q=hi")
def test_shared_recursive_refs_scale(self):
# a self-referential schema reused across many operations must terminate promptly (depth cap +
# per-$ref memoization); without them this would blow up exponentially and hang the test
schemas = {"Node": {"type": "object", "properties": {
"name": {"type": "string"},
"child": {"$ref": "#/components/schemas/Node"},
"list": {"type": "array", "items": {"$ref": "#/components/schemas/Node"}}}}}
paths = dict(("/n%d" % i, {"post": {"requestBody": {"content": {"application/json":
{"schema": {"$ref": "#/components/schemas/Node"}}}}}}) for i in range(60))
targets = _targets({"openapi": "3.0.0", "components": {"schemas": schemas}, "paths": paths})
self.assertEqual(len(targets), 60)
self.assertEqual(json.loads(targets[0][2]).get("name"), "1")
def test_swagger_v2_formdata_body(self):
# in:"formData" params must become a urlencoded body (previously dropped -> empty POST)
spec = {"swagger": "2.0", "host": "h", "paths": {"/l": {"post": {"parameters": [
{"name": "u", "in": "formData", "type": "string"},
{"name": "p", "in": "formData", "type": "string"}]}}}}
url, method, data, headers = _targets(spec, None)[0]
self.assertEqual(method, "POST")
self.assertEqual(sorted(data.split("&")), ["p=1", "u=1"])
def test_relative_base_is_skipped(self):
# a spec that yields no scheme/host (relative server + no origin) must be skipped, not emitted
spec = {"openapi": "3.0.0", "servers": [{"url": "/api"}], "paths": {"/x": {"get": {}}}}
self.assertEqual(openApiTargets(json.dumps(spec), None), []) # relative -> skipped
self.assertEqual(len(openApiTargets(json.dumps(spec), "http://h")), 1) # absolute with origin -> kept
def test_unsupported_body_media_type_no_crash(self):
# a structured body under a non-JSON/form media type must not crash and must not fabricate a body,
# but the endpoint URL is still produced
spec = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/xml":
{"schema": {"type": "object", "properties": {"a": {"type": "string"}}}}}}}}}}
url, method, data, headers = _targets(spec)[0]
self.assertEqual((url, method, data), ("http://h/x", "POST", None))
def test_injection_mark_char_in_value_is_not_doubled(self):
# an example value already containing the custom injection mark must not create a stray point
from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK
spec = {"openapi": "3.0.0", "paths": {"/x": {"post": {
"parameters": [{"name": "H", "in": "header", "schema": {"type": "string", "example": "a%sb" % MARK}}],
"requestBody": {"content": {"application/json": {"schema": {"type": "object",
"properties": {"n": {"type": "string", "example": "x%sy" % MARK}}}}}}}}}}
url, method, data, headers = _targets(spec)[0]
self.assertEqual(dict(headers)["H"], "ab" + MARK) # single trailing mark only
self.assertEqual(json.loads(data), {"n": "xy"}) # mark stripped from body value
@unittest.skipUnless(HAS_YAML, "pyyaml not available")
def test_non_string_method_keys_do_not_crash(self):
# YAML path-item keys are not guaranteed to be strings (404 -> int, on -> bool); must not crash
y = ("openapi: 3.0.0\n"
"servers: [{url: 'http://h'}]\n"
"paths:\n"
" /x:\n"
" get: {}\n"
" 404: {}\n"
" on: {}\n")
targets = openApiTargets(y, "http://h")
self.assertEqual(len(targets), 1) # only the real GET operation
self.assertEqual(targets[0][1], "GET")
def test_hostile_base_url_metadata_does_not_crash(self):
# _baseUrl runs once, OUTSIDE the per-operation try, so malformed server/scheme/basePath metadata
# must not raise (it would abort the entire extraction)
hostile = [
{"openapi": "3.0.0", "servers": [{"url": "https://{e}.x/", "variables": [1, 2]}], "paths": {"/x": {"get": {}}}},
{"openapi": "3.0.0", "servers": [{"url": "https://{e}.x/", "variables": {"e": "prod"}}], "paths": {"/x": {"get": {}}}},
{"openapi": "3.0.0", "servers": [{"url": 123}], "paths": {"/x": {"get": {}}}},
{"swagger": "2.0", "host": "h", "schemes": {"a": 1}, "paths": {"/x": {"get": {}}}},
{"swagger": "2.0", "host": "h", "basePath": 123, "paths": {"/x": {"get": {}}}}]
for spec in hostile:
self.assertEqual(len(_targets(spec)), 1) # no crash, still one target
def test_param_entry_not_a_dict_is_skipped(self):
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": ["oops", {"name": "q", "in": "query"}]}}}}
self.assertIn("q=1", _targets(spec)[0][0]) # bad entry skipped, good one still used
@unittest.skipUnless(HAS_YAML, "pyyaml not available")
def test_yaml_date_examples_serialize(self):
# unquoted YAML dates parse to datetime.date, which is not JSON-serializable -> must be stringified,
# not silently dropped (dates are pervasive in real specs)
y = ("openapi: 3.0.0\n"
"servers: [{url: 'http://h'}]\n"
"paths:\n"
" /x:\n"
" post:\n"
" requestBody:\n"
" content:\n"
" application/json:\n"
" schema: {type: object, properties: {created: {type: string, example: 2020-01-01}}}\n")
url, method, data, headers = openApiTargets(y, "http://h")[0]
self.assertEqual(json.loads(data), {"created": "2020-01-01"})
def test_crlf_in_header_and_cookie_is_stripped(self):
# a spec-supplied header/cookie name or value must not carry CR/LF (header injection / request
# corruption); query/path values are separately percent-encoded
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "X-A", "in": "header", "schema": {"type": "string", "example": "a\r\nX-Evil: 1"}},
{"name": "X\r\nB", "in": "header", "schema": {"type": "string", "example": "v"}},
{"name": "sid", "in": "cookie", "schema": {"type": "string", "example": "a\r\nSet: x"}}]}}}}
headers = dict(_targets(spec)[0][3])
for name, value in headers.items():
self.assertNotIn("\r", name + value)
self.assertNotIn("\n", name + value)
self.assertIn("X-A", headers)
self.assertIn("XB", headers) # control chars removed from the name
def test_explicit_examples_preferred_over_schema(self):
# a concrete example/examples on the media-type or parameter object must win over schema synthesis
# (real specs carry the canonical, validation-passing value there)
body = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": {
"schema": {"type": "object", "properties": {"name": {"type": "string"}}}, "example": {"name": "real"}}}}}}}}
self.assertEqual(json.loads(_targets(body)[0][2]), {"name": "real"})
examples = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": {
"schema": {"type": "object"}, "examples": {"first": {"value": {"k": "v1"}}}}}}}}}}
self.assertEqual(json.loads(_targets(examples)[0][2]), {"k": "v1"})
param = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "q", "in": "query", "example": "E", "schema": {"type": "string"}}]}}}}
self.assertIn("q=E", _targets(param)[0][0])
def test_openapi_31_const_and_type_array(self):
spec = {"openapi": "3.1.0", "paths": {"/x": {"get": {"parameters": [
{"name": "c", "in": "query", "schema": {"const": "CV"}},
{"name": "n", "in": "query", "schema": {"type": ["integer", "null"]}}]}}}}
url = _targets(spec)[0][0]
self.assertIn("c=CV", url) # const used
self.assertIn("n=1", url) # ["integer","null"] resolved to integer, not the generic fallback
def test_parameter_names_are_encoded(self):
# a param NAME with structural chars must be encoded so it can not split/smuggle params or truncate
# at a fragment; deep-object brackets ([]) are preserved
spec = {"openapi": "3.0.0", "paths": {
"/q": {"get": {"parameters": [
{"name": "a&b=c", "in": "query", "schema": {"type": "string"}},
{"name": "a#b", "in": "query", "schema": {"type": "string"}},
{"name": "filter[status]", "in": "query", "schema": {"type": "string"}}]}},
"/f": {"post": {"requestBody": {"content": {"application/x-www-form-urlencoded":
{"schema": {"type": "object", "properties": {"x&y": {"type": "string"}}}}}}}}}}
byMethod = dict((method, (url, data)) for url, method, data, headers in _targets(spec))
getUrl = byMethod["GET"][0]
self.assertIn("a%26b%3Dc=1", getUrl)
self.assertIn("a%23b=1", getUrl)
self.assertIn("filter[status]=1", getUrl) # brackets kept (deep-object param names)
self.assertNotIn("#", getUrl)
self.assertEqual(byMethod["POST"][1], "x%26y=1")
def test_undefined_template_var_does_not_leak(self):
# a server/path template variable with no definition must not leave a literal '{...}' in the URL
spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.x.com/{basePath}/v3"}],
"paths": {"/pets": {"get": {}}}}
url = _targets(spec, "http://h")[0][0]
self.assertNotIn("{", url)
self.assertEqual(url, "https://api.x.com/1/v3/pets") # absolute server used as-is (host not rewritten)
def test_absolute_server_url_is_not_rewritten_to_origin(self):
# a spec served from one host but declaring an absolute API server on another host must scan the
# DECLARED API host, not the spec's origin
spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.example.com/v1"}],
"paths": {"/pets": {"get": {}}}}
self.assertEqual(_targets(spec, "https://docs.example.com")[0][0], "https://api.example.com/v1/pets")
def test_path_parameter_is_injection_marked(self):
from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK
spec = {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {"parameters": [
{"name": "id", "in": "path", "schema": {"type": "integer"}}]}}}}
self.assertEqual(_targets(spec)[0][0], "http://h/users/1" + MARK)
def test_form_urlencoded_sets_content_type_and_multipart_skipped(self):
form = {"openapi": "3.0.0", "paths": {"/f": {"post": {"requestBody": {"content":
{"application/x-www-form-urlencoded": {"schema": {"type": "object", "properties": {"u": {"type": "string"}}}}}}}}}}
url, method, data, headers = _targets(form)[0]
self.assertEqual(data, "u=1")
self.assertIn(("Content-Type", "application/x-www-form-urlencoded"), headers)
multipart = {"openapi": "3.0.0", "paths": {"/m": {"post": {"requestBody": {"content":
{"multipart/form-data": {"schema": {"type": "object", "properties": {"u": {"type": "string"}}}}}}}}}}
url, method, data, headers = _targets(multipart)[0]
self.assertIsNone(data) # multipart is skipped, not mis-serialized as urlencoded
def test_path_item_ref_is_resolved(self):
spec = {"openapi": "3.1.0",
"components": {"pathItems": {"Ping": {"get": {"parameters": [
{"name": "q", "in": "query", "schema": {"type": "string", "example": "z"}}]}}}},
"paths": {"/ping": {"$ref": "#/components/pathItems/Ping"}}}
targets = _targets(spec)
self.assertEqual(len(targets), 1)
self.assertIn("q=z", targets[0][0])
def test_operation_parameter_overrides_path_level(self):
spec = {"openapi": "3.0.0", "paths": {"/x": {
"parameters": [{"name": "q", "in": "query", "schema": {"type": "string", "example": "shared"}}],
"get": {"parameters": [{"name": "q", "in": "query", "schema": {"type": "string", "example": "op"}}]}}}}
url = _targets(spec)[0][0]
self.assertIn("q=op", url) # operation value wins
self.assertEqual(url.count("q="), 1) # not duplicated
def test_multiple_cookies_aggregate_into_one_header(self):
from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "a", "in": "cookie", "schema": {"type": "string"}},
{"name": "b", "in": "cookie", "schema": {"type": "string"}}]}}}}
headers = _targets(spec)[0][3]
cookieHeaders = [v for (k, v) in headers if k == "Cookie"]
self.assertEqual(cookieHeaders, ["a=1%s; b=1%s" % (MARK, MARK)]) # one aggregated Cookie header
def test_cookie_name_value_cannot_smuggle_pairs(self):
# a cookie name that is not a token is dropped; structural chars in the value ('; ,' / whitespace)
# are stripped so a spec can not inject additional cookie pairs
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "a; injected", "in": "cookie", "schema": {"type": "string"}},
{"name": "sid", "in": "cookie", "schema": {"type": "string", "example": "v; z=1"}}]}}}}
cookieHeaders = [v for (k, v) in (_targets(spec)[0][3] or []) if k == "Cookie"]
self.assertEqual(len(cookieHeaders), 1)
cookie = cookieHeaders[0]
self.assertNotIn(";", cookie.rstrip("*")) # no interior ';' -> no smuggled pair
self.assertNotIn("injected", cookie) # invalid cookie name dropped
self.assertNotIn(" ", cookie)
def test_loose_path_without_leading_slash(self):
# a malformed path key missing its leading '/' must not glue onto the base (".../v1pets")
spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.x/v1"}], "paths": {"pets": {"get": {}}}}
self.assertEqual(_targets(spec, None)[0][0], "https://api.x/v1/pets")
def test_array_query_param_is_best_effort_scalar(self):
# documents current best-effort behavior: an array query param is scalarized+encoded, NOT expanded
# per style/explode. If richer serialization is added later, update this expectation deliberately.
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "ids", "in": "query", "schema": {"type": "array", "items": {"type": "integer"}}}]}}}}
url = _targets(spec)[0][0]
self.assertIn("ids=", url)
self.assertNotIn(" ", url) # whatever the encoding, it must not break the URL
self.assertTrue(url.startswith("http://h/x?ids="))
def test_invalid_header_name_is_skipped(self):
spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "Bad Name", "in": "header", "schema": {"type": "string"}},
{"name": "Also:Bad", "in": "header", "schema": {"type": "string"}},
{"name": "X-Good", "in": "header", "schema": {"type": "string"}}]}}}}
headers = dict(_targets(spec)[0][3] or [])
self.assertIn("X-Good", headers)
self.assertNotIn("Bad Name", headers)
self.assertNotIn("Also:Bad", headers)
def test_explicit_null_example_falls_back_to_schema(self):
# 'example: null' must not serialize as null/"null" - fall back to schema synthesis
q = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [
{"name": "q", "in": "query", "example": None, "schema": {"type": "string", "example": "good"}}]}}}}
self.assertIn("q=good", _targets(q)[0][0])
b = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json":
{"example": None, "schema": {"type": "object", "properties": {"a": {"type": "integer"}}}}}}}}}}
self.assertEqual(json.loads(_targets(b)[0][2]), {"a": 1})
def test_degrade_not_skip_on_odd_shapes(self):
# enum-as-dict, non-string param name, and content[type]-as-list must degrade (op preserved)
for spec in (
{"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [{"name": "q", "in": "query", "schema": {"enum": {"a": 1}}}]}}}},
{"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [{"name": 5, "in": "header", "schema": {"type": "string"}}]}}}},
{"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": [1, 2]}}}}}}):
self.assertEqual(len(_targets(spec)), 1)
def test_malformed_ref_and_properties_degrade_not_skip(self):
# a non-string/unhashable $ref or a non-dict 'properties' must degrade the value (not lose the op)
for schema in ({"$ref": 123}, {"$ref": [1, 2]}, {"type": "object", "properties": [1, 2]}):
spec = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody":
{"content": {"application/json": {"schema": schema}}}}}}}
self.assertEqual(len(_targets(spec)), 1) # operation preserved, not skipped
def test_undefined_bits_are_skipped_not_fatal(self):
spec = {"openapi": "3.0.0", "paths": {
"/a": {"get": {"parameters": [{}]}}, # param with no name
"/b": {"post": {"requestBody": {"content": {"application/json":
{"schema": {"$ref": "#/components/schemas/DoesNotExist"}}}}}}, # dangling $ref
"/c": {"get": {"parameters": [{"name": "p", "in": "query",
"schema": {"$ref": "https://other/x.json#/Y"}}]}}}} # external $ref
targets = _targets(spec)
self.assertEqual(len(targets), 3) # all three still produced
if __name__ == "__main__":
unittest.main()