Skip to content

Commit a6ca652

Browse files
committed
Lockout agent command results if a token is received
This is a second attempt at securing the get command output endpoint which could have data such as logs which could potentially have sensitive details and information after the agent has completed one or more actions. Now, if a token is receieved, the agent locks out the command results endpoint, and requires all future calls to include it. This allows for the agent to be backwards compatible. Special thanks go to cid for his first attempt at this, which I took for the basis of some of the testing required. Closes-Bug: #2086866 Co-Authored-By: cid@gr-oss.io Change-Id: Ia39a3894ef5efaffd7e1d22cc6244059a32175ff
1 parent 8ab0bfb commit a6ca652

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

ironic_python_agent/api/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def __init__(self, agent, conf):
113113
routing.Rule('/commands/', endpoint='run_command',
114114
methods=['POST']),
115115
])
116+
self.security_get_token_support = False
116117

117118
def __call__(self, environ, start_response):
118119
"""WSGI entry point."""
@@ -199,11 +200,27 @@ def api_status(self, request):
199200
status = self.agent.get_status()
200201
return jsonify(status)
201202

203+
def require_agent_token_for_command(func):
204+
def wrapper(self, request, *args, **kwargs):
205+
token = request.args.get('agent_token', None)
206+
if token:
207+
# TODO(TheJulia): At some point down the road, remove the
208+
# self.security_get_token_support flag and use the same
209+
# decorator for the api_run_command endpoint.
210+
self.security_get_token_support = True
211+
if (self.security_get_token_support
212+
and not self.agent.validate_agent_token(token)):
213+
raise http_exc.Unauthorized('Token invalid.')
214+
return func(self, request, *args, **kwargs)
215+
return wrapper
216+
217+
@require_agent_token_for_command
202218
def api_list_commands(self, request):
203219
with metrics_utils.get_metrics_logger(__name__).timer('list_commands'):
204220
results = self.agent.list_command_results()
205221
return jsonify({'commands': results})
206222

223+
@require_agent_token_for_command
207224
def api_get_command(self, request, cmd):
208225
with metrics_utils.get_metrics_logger(__name__).timer('get_command'):
209226
result = self.agent.get_command_result(cmd)

ironic_python_agent/tests/unit/test_api.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,32 @@ def test_list_command_results(self):
260260
],
261261
}, response.json)
262262

263+
def test_list_commands_with_token(self):
264+
agent_token = str('0123456789' * 10)
265+
cmd_result = base.SyncCommandResult('do_things',
266+
{'key': 'value'},
267+
True,
268+
{'test': 'result'})
269+
self.mock_agent.list_command_results.return_value = [cmd_result]
270+
self.mock_agent.validate_agent_token.return_value = True
271+
272+
response = self.get_json('/commands?agent_token=%s' % agent_token)
273+
274+
self.assertEqual(200, response.status_code)
275+
self.assertEqual(1, self.mock_agent.validate_agent_token.call_count)
276+
self.assertEqual(1, self.mock_agent.list_command_results.call_count)
277+
278+
def test_list_commands_with_token_invalid(self):
279+
agent_token = str('0123456789' * 10)
280+
self.mock_agent.validate_agent_token.return_value = False
281+
282+
response = self.get_json('/commands?agent_token=%s' % agent_token,
283+
expect_errors=True)
284+
285+
self.assertEqual(401, response.status_code)
286+
self.assertEqual(1, self.mock_agent.validate_agent_token.call_count)
287+
self.assertEqual(0, self.mock_agent.list_command_results.call_count)
288+
263289
def test_get_command_result(self):
264290
cmd_result = base.SyncCommandResult('do_things',
265291
{'key': 'value'},
@@ -274,6 +300,76 @@ def test_get_command_result(self):
274300
data = response.json
275301
self.assertEqual(serialized_cmd_result, data)
276302

303+
def test_get_command_with_token(self):
304+
agent_token = str('0123456789' * 10)
305+
cmd_result = base.SyncCommandResult('do_things',
306+
{'key': 'value'},
307+
True,
308+
{'test': 'result'})
309+
self.mock_agent.get_command_result.return_value = cmd_result
310+
self.mock_agent.validate_agent_token.return_value = True
311+
312+
response = self.get_json(
313+
'/commands/abc123?agent_token=%s' % agent_token)
314+
315+
self.assertEqual(200, response.status_code)
316+
self.assertEqual(cmd_result.serialize(), response.json)
317+
self.assertEqual(1, self.mock_agent.validate_agent_token.call_count)
318+
self.assertEqual(1, self.mock_agent.get_command_result.call_count)
319+
320+
def test_get_command_with_token_invalid(self):
321+
agent_token = str('0123456789' * 10)
322+
self.mock_agent.validate_agent_token.return_value = False
323+
324+
response = self.get_json(
325+
'/commands/abc123?agent_token=%s' % agent_token,
326+
expect_errors=True)
327+
328+
self.assertEqual(401, response.status_code)
329+
self.assertEqual(1, self.mock_agent.validate_agent_token.call_count)
330+
self.assertEqual(0, self.mock_agent.get_command_result.call_count)
331+
332+
def test_get_command_locks_out_with_token(self):
333+
"""Tests agent backwards compatibility and verifies upgrade lockout."""
334+
cmd_result = base.SyncCommandResult('do_things',
335+
{'key': 'value'},
336+
True,
337+
{'test': 'result'})
338+
cmd_result.serialize()
339+
self.mock_agent.get_command_result.return_value = cmd_result
340+
agent_token = str('0123456789' * 10)
341+
self.mock_agent.validate_agent_token.return_value = False
342+
343+
# Backwards compatible operation check.
344+
response = self.get_json(
345+
'/commands/abc123')
346+
self.assertEqual(200, response.status_code)
347+
self.assertFalse(self.app.security_get_token_support)
348+
self.assertEqual(1, self.mock_agent.get_command_result.call_count)
349+
self.mock_agent.reset_mock()
350+
351+
# Check with a newer ironic sending an agent_token upon the command.
352+
# For context, in this case the token is wrong intentionally.
353+
# It doesn't have to be right, but what we're testing is the
354+
# submission of any value triggers the lockout
355+
response = self.get_json(
356+
'/commands/abc123?agent_token=%s' % agent_token,
357+
expect_errors=True)
358+
self.assertTrue(self.app.security_get_token_support)
359+
self.assertEqual(401, response.status_code)
360+
self.assertEqual(1, self.mock_agent.validate_agent_token.call_count)
361+
self.assertEqual(0, self.mock_agent.get_command_result.call_count)
362+
363+
# Verifying the lockout is now being enforced and that agent token
364+
# is now required by the agent.
365+
response = self.get_json(
366+
'/commands/abc123', expect_errors=True)
367+
self.assertTrue(self.app.security_get_token_support)
368+
self.assertEqual(401, response.status_code)
369+
self.assertEqual(0, self.mock_agent.get_command_result.call_count)
370+
# Verify we still called validate_agent_token
371+
self.assertEqual(2, self.mock_agent.validate_agent_token.call_count)
372+
277373
def test_execute_agent_command_with_token(self):
278374
agent_token = str('0123456789' * 10)
279375
command = {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
fixes:
3+
- |
4+
Fixes a potential security issue where a third party may be able to
5+
retrieve potentially sensitive data in command result output from
6+
the agent. If a request comes in with an ``agent_token`` to the
7+
command results endpoint, the agent will now require all future
8+
calls to leverage the token to retrieve results and validate
9+
that token's validity. This effectively eliminates the possibility
10+
of a malicious entity with access to the agent's API endpoint from
11+
capturing the command results from agent operations.

0 commit comments

Comments
 (0)