Skip to content

Commit 671a151

Browse files
[CDRIVER-4454] Azure auto-KMS testing (#1104)
* Move API for IMDS HTTP, support host and port overrides * Live tests for Azure IMDS requests * Add a very simple mock IMDS server based on Bottle * New error types for Azure * A simple timer abstraction * HTTP fixes: - A timeout during a partial read is still an error. - Prevent a slow server from trickling data and causing an eternal wait (Keep track of time while reading) - Reject very large HTTP responses. * Test cases by prompting server misbehavior from the client * Update errors listings * Saturating time conversions
1 parent 0648bef commit 671a151

File tree

11 files changed

+4498
-174
lines changed

11 files changed

+4498
-174
lines changed

build/bottle.py

Lines changed: 3806 additions & 0 deletions
Large diffs are not rendered by default.

build/fake_azure.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import time
5+
import json
6+
import traceback
7+
import functools
8+
import bottle
9+
from bottle import Bottle, HTTPResponse, request
10+
11+
imds = Bottle(autojson=True)
12+
"""An Azure IMDS server"""
13+
14+
from typing import TYPE_CHECKING, Any, Callable, Iterable, overload
15+
16+
if TYPE_CHECKING:
17+
from typing import Protocol
18+
19+
class _RequestParams(Protocol):
20+
21+
def __getitem__(self, key: str) -> str:
22+
...
23+
24+
@overload
25+
def get(self, key: str) -> str | None:
26+
...
27+
28+
@overload
29+
def get(self, key: str, default: str) -> str:
30+
...
31+
32+
class _HeadersDict(dict[str, str]):
33+
34+
def raw(self, key: str) -> bytes | None:
35+
...
36+
37+
class _Request(Protocol):
38+
query: _RequestParams
39+
params: _RequestParams
40+
headers: _HeadersDict
41+
42+
request: _Request
43+
44+
45+
def parse_qs(qs: str) -> dict[str, str]:
46+
return dict(bottle._parse_qsl(qs)) # type: ignore
47+
48+
49+
def require(cond: bool, message: str):
50+
if not cond:
51+
print(f'REQUIREMENT FAILED: {message}')
52+
raise bottle.HTTPError(400, message)
53+
54+
55+
_HandlerFuncT = Callable[
56+
[],
57+
'None|str|bytes|dict[str, Any]|bottle.BaseResponse|Iterable[bytes|str]']
58+
59+
60+
def handle_asserts(fn: _HandlerFuncT) -> _HandlerFuncT:
61+
62+
@functools.wraps(fn)
63+
def wrapped():
64+
try:
65+
return fn()
66+
except AssertionError as e:
67+
traceback.print_exc()
68+
return bottle.HTTPResponse(status=400,
69+
body=json.dumps({'error':
70+
list(e.args)}))
71+
72+
return wrapped
73+
74+
75+
def test_flags() -> dict[str, str]:
76+
return parse_qs(request.headers.get('X-MongoDB-HTTP-TestParams', ''))
77+
78+
79+
def maybe_pause():
80+
pause = int(test_flags().get('pause', '0'))
81+
if pause:
82+
print(f'Pausing for {pause} seconds')
83+
time.sleep(pause)
84+
85+
86+
@imds.get('/metadata/identity/oauth2/token')
87+
@handle_asserts
88+
def get_oauth2_token():
89+
api_version = request.query['api-version']
90+
assert api_version == '2018-02-01', 'Only api-version=2018-02-01 is supported'
91+
resource = request.query['resource']
92+
assert resource == 'https://vault.azure.net', 'Only https://vault.azure.net is supported'
93+
94+
flags = test_flags()
95+
maybe_pause()
96+
97+
case = flags.get('case')
98+
print('Case is:', case)
99+
if case == '404':
100+
return HTTPResponse(status=404)
101+
102+
if case == '500':
103+
return HTTPResponse(status=500)
104+
105+
if case == 'bad-json':
106+
return b'{"key": }'
107+
108+
if case == 'empty-json':
109+
return b'{}'
110+
111+
if case == 'giant':
112+
return _gen_giant()
113+
114+
if case == 'slow':
115+
return _slow()
116+
117+
assert case is None or case == '', f'Unknown HTTP test case "{case}"'
118+
119+
return {
120+
'access_token': 'magic-cookie',
121+
'expires_in': '60',
122+
'token_type': 'Bearer',
123+
'resource': 'https://vault.azure.net',
124+
}
125+
126+
127+
def _gen_giant() -> Iterable[bytes]:
128+
yield b'{ "item": ['
129+
for _ in range(1024 * 256):
130+
yield (b'null, null, null, null, null, null, null, null, null, null, '
131+
b'null, null, null, null, null, null, null, null, null, null, '
132+
b'null, null, null, null, null, null, null, null, null, null, '
133+
b'null, null, null, null, null, null, null, null, null, null, ')
134+
yield b' null ] }'
135+
yield b'\n'
136+
137+
138+
def _slow() -> Iterable[bytes]:
139+
yield b'{ "item": ['
140+
for _ in range(1000):
141+
yield b'null, '
142+
time.sleep(1)
143+
yield b' null ] }'
144+
145+
146+
if __name__ == '__main__':
147+
print(f'RECOMMENDED: Run this script using bottle.py in the same '
148+
f'directory (e.g. [{sys.executable} bottle.py fake_azure:imds])')
149+
imds.run()

src/libmongoc/doc/errors.rst

Lines changed: 89 additions & 85 deletions
Large diffs are not rendered by default.

src/libmongoc/src/mongoc/mcd-azure.c

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,42 @@ static const char *const DEFAULT_METADATA_PATH =
2626
"&resource=https%3A%2F%2Fvault.azure.net";
2727

2828
void
29-
mcd_azure_imds_request_init (mcd_azure_imds_request *req)
29+
mcd_azure_imds_request_init (mcd_azure_imds_request *req,
30+
const char *const opt_imds_host,
31+
int opt_port,
32+
const char *const opt_extra_headers)
3033
{
3134
BSON_ASSERT_PARAM (req);
3235
_mongoc_http_request_init (&req->req);
3336
// The HTTP host of the IMDS server
34-
req->req.host = "169.254.169.254";
35-
req->req.port = 80;
37+
req->req.host = req->_owned_host =
38+
bson_strdup (opt_imds_host ? opt_imds_host : "169.254.169.254");
39+
if (opt_port) {
40+
req->req.port = opt_port;
41+
} else {
42+
req->req.port = 80;
43+
}
3644
// No body
3745
req->req.body = "";
3846
// We GET
3947
req->req.method = "GET";
4048
// 'Metadata: true' is required
41-
req->req.extra_headers = "Metadata: true\r\n"
42-
"Accept: application/json\r\n";
49+
req->req.extra_headers = req->_owned_headers =
50+
bson_strdup_printf ("Metadata: true\r\n"
51+
"Accept: application/json\r\n%s",
52+
opt_extra_headers ? opt_extra_headers : "");
4353
// The default path is suitable. In the future, we may want to add query
4454
// parameters to disambiguate a managed identity.
45-
req->req.path = bson_strdup (DEFAULT_METADATA_PATH);
55+
req->req.path = req->_owned_path = bson_strdup (DEFAULT_METADATA_PATH);
4656
}
4757

4858
void
4959
mcd_azure_imds_request_destroy (mcd_azure_imds_request *req)
5060
{
5161
BSON_ASSERT_PARAM (req);
52-
bson_free ((void *) req->req.path);
62+
bson_free (req->_owned_path);
63+
bson_free (req->_owned_host);
64+
bson_free (req->_owned_headers);
5365
*req = (mcd_azure_imds_request){0};
5466
}
5567

@@ -97,8 +109,8 @@ mcd_azure_access_token_try_init_from_json_str (mcd_azure_access_token *out,
97109
if (!(access_token && resource && token_type && expires_in_str)) {
98110
bson_set_error (
99111
error,
100-
MONGOC_ERROR_PROTOCOL_ERROR,
101-
64,
112+
MONGOC_ERROR_AZURE,
113+
MONGOC_ERROR_AZURE_BAD_JSON,
102114
"One or more required JSON properties are missing/invalid: data: %.*s",
103115
len,
104116
json);
@@ -118,8 +130,8 @@ mcd_azure_access_token_try_init_from_json_str (mcd_azure_access_token *out,
118130
// Did not parse the entire string. Bad
119131
bson_set_error (
120132
error,
121-
MONGOC_ERROR_PROTOCOL,
122-
65,
133+
MONGOC_ERROR_AZURE,
134+
MONGOC_ERROR_AZURE_BAD_JSON,
123135
"Invalid 'expires_in' string \"%.*s\" from IMDS server",
124136
expires_in_len,
125137
expires_in_str);
@@ -144,3 +156,56 @@ mcd_azure_access_token_destroy (mcd_azure_access_token *c)
144156
c->resource = NULL;
145157
c->token_type = NULL;
146158
}
159+
160+
161+
bool
162+
mcd_azure_access_token_from_imds (mcd_azure_access_token *const out,
163+
const char *const opt_imds_host,
164+
int opt_port,
165+
const char *opt_extra_headers,
166+
bson_error_t *error)
167+
{
168+
BSON_ASSERT_PARAM (out);
169+
170+
bool okay = false;
171+
172+
// Clear the output
173+
*out = (mcd_azure_access_token){0};
174+
175+
mongoc_http_response_t resp;
176+
_mongoc_http_response_init (&resp);
177+
178+
mcd_azure_imds_request req = {0};
179+
mcd_azure_imds_request_init (
180+
&req, opt_imds_host, opt_port, opt_extra_headers);
181+
182+
if (!_mongoc_http_send (&req.req, 3 * 1000, false, NULL, &resp, error)) {
183+
_mongoc_http_response_cleanup (&resp);
184+
goto fail;
185+
}
186+
187+
// We only accept an HTTP 200 as a success
188+
if (resp.status != 200) {
189+
bson_set_error (error,
190+
MONGOC_ERROR_AZURE,
191+
MONGOC_ERROR_AZURE_HTTP,
192+
"Error from Azure IMDS server while looking for "
193+
"Managed Identity access token: %.*s",
194+
resp.body_len,
195+
resp.body);
196+
goto fail;
197+
}
198+
199+
// Parse the token from the response JSON
200+
if (!mcd_azure_access_token_try_init_from_json_str (
201+
out, resp.body, resp.body_len, error)) {
202+
goto fail;
203+
}
204+
205+
okay = true;
206+
207+
fail:
208+
mcd_azure_imds_request_destroy (&req);
209+
_mongoc_http_response_cleanup (&resp);
210+
return okay;
211+
}

src/libmongoc/src/mongoc/mcd-azure.h

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,27 @@ mcd_azure_access_token_destroy (mcd_azure_access_token *token);
7575
typedef struct mcd_azure_imds_request {
7676
/// The underlying HTTP request object to be sent
7777
mongoc_http_request_t req;
78+
char *_owned_path;
79+
char *_owned_host;
80+
char *_owned_headers;
7881
} mcd_azure_imds_request;
7982

8083
/**
8184
* @brief Initialize a new IMDS HTTP request
8285
*
8386
* @param out The object to initialize
87+
* @param opt_imds_host (Optional) the IP host of the IMDS server
88+
* @param opt_port (Optional) The port of the IMDS HTTP server (default is 80)
89+
* @param opt_extra_headers (Optional) Set extra HTTP headers for the request
8490
*
8591
* @note the request must later be destroyed with mcd_azure_imds_request_destroy
92+
* @note Currently only supports the vault.azure.net resource
8693
*/
8794
void
88-
mcd_azure_imds_request_init (mcd_azure_imds_request *out);
95+
mcd_azure_imds_request_init (mcd_azure_imds_request *out,
96+
const char *opt_imds_host,
97+
int opt_port,
98+
const char *opt_extra_headers);
8999

90100
/**
91101
* @brief Destroy an IMDS request created with mcd_azure_imds_request_init()
@@ -95,4 +105,26 @@ mcd_azure_imds_request_init (mcd_azure_imds_request *out);
95105
void
96106
mcd_azure_imds_request_destroy (mcd_azure_imds_request *req);
97107

108+
/**
109+
* @brief Attempt to obtain a new OAuth2 access token from an Azure IMDS HTTP
110+
* server.
111+
*
112+
* @param out The output parameter for the obtained token. Must later be
113+
* destroyed
114+
* @param opt_imds_host (Optional) Override the IP host of the IMDS server
115+
* @param opt_port (Optional) The port of the IMDS HTTP server (default is 80)
116+
* @param opt_extra_headers (Optional) Set extra HTTP headers for the request
117+
* @param error Output parameter for errors
118+
* @retval true Upon success
119+
* @retval false Otherwise. Sets an error via `error`
120+
*
121+
* @note Currently only supports the vault.azure.net resource
122+
*/
123+
bool
124+
mcd_azure_access_token_from_imds (mcd_azure_access_token *out,
125+
const char *opt_imds_host,
126+
int opt_port,
127+
const char *opt_extra_headers,
128+
bson_error_t *error);
129+
98130
#endif // MCD_AZURE_H_INCLUDED

0 commit comments

Comments
 (0)