Fix intersphinx leaking basic auth credentials in error and info messages - #14366
Fix intersphinx leaking basic auth credentials in error and info messages#14366joshuaswanson wants to merge 13 commits into
Conversation
|
Thanks for working on a fix for this @joshuaswanson - please could you add some test coverage to accompany it? I would recommend copying and adapting the |
|
|
||
| get_request.side_effect = ConnectionError('connection refused') | ||
| with pytest.raises(ConnectionError, match='connection refused'): | ||
| _fetch_inventory_url( |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| cache_path=None, | ||
| ) | ||
| status_output = app.status.getvalue() | ||
| assert 'secret' not in status_output |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
…ial leak in error string
| @mock.patch('sphinx.ext.intersphinx._load.InventoryFile') | ||
| @mock.patch('sphinx.ext.intersphinx._load.requests.get') | ||
| @pytest.mark.sphinx('html', testroot='root') | ||
| def test_fetch_inventory_redirect_hides_credentials(get_request, InventoryFile, app): | ||
| """Credentials should not appear in redirect log messages.""" | ||
| mocked_get = get_request.return_value.__enter__.return_value | ||
| intersphinx_setup(app) | ||
| mocked_get.content = b'# Sphinx inventory version 2' |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
|
@picnixz are you able to review this pull request too? |
| _, stderr = capsys.readouterr() | ||
| assert 'secret' not in stderr |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| status_output = app.status.getvalue() | ||
| assert 'secret' not in status_output | ||
| assert 'user@localhost' in status_output |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| stdout, stderr = capsys.readouterr() | ||
| full_output = stdout + stderr + '\n'.join(caplog.messages) | ||
| assert 'secret' not in full_output | ||
| assert 'user@localhost' in full_output |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| with http_server(RedirectHandler) as server: | ||
| port = server.server_port | ||
| url = f'http://user:secret@localhost:{port}/{INVENTORY_FILENAME}' | ||
| inspect_main([url]) |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| assert actual == expected | ||
|
|
||
|
|
||
| def test_fetch_inventory_url_error_hides_credentials(capsys): |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
jayaddison
left a comment
There was a problem hiding this comment.
Looks great - thanks for this and for your responsiveness to codereview, @joshuaswanson
|
@joshuaswanson sorry, one more thing that I forgot: you should also update the |
|
👍 thanks again |
| def do_GET(self): | ||
| if '/new/' not in self.path: | ||
| self.send_response(302) | ||
| new_url = f'http://localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}' |
There was a problem hiding this comment.
There are two issues with this line:
-
The production redirect log message contains two independently redacted values: the original inventory URL (
inv_location) and the final URL after following the redirect (new_inv_location). The current test gives only the original URL a password:original: http://user:secret@localhost/objects.inv destination: http://localhost/new/objects.invThat verifies redaction of
inv_location, but it cannot verify redaction ofnew_inv_location. For example, if someone accidentally changed the implementation to the following, the current test would still pass:LOGGER.info(msg, _get_safe_url(inv_location), new_inv_location)
The second URL is now logged without redaction, but it has no password to leak, so the assertion looking for
secretdetects nothing.Giving the destination its own credentials closes that coverage gap:
destination: http://redirect-user:redirect-secret@localhost/new/objects.invThe correct implementation logs
redirect-user@localhost; an implementation that omits_get_safe_url(new_inv_location)logsredirect-secretand fails the existing assertion. This makes the test protect both halves of the redirect message from future regressions.- new_url = f'http://localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}' + new_url = f'http://redirect-user:redirect-secret@localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}'
-
The
tycheck fails here becauseBaseHTTPRequestHandler.serveris typed asBaseServer, which does not expose aserver_portattribute. An explicit type narrowing should preserve the current behavior while allowingtyto recognize the concrete server type:+ assert isinstance(self.server, http.server.HTTPServer) new_url = f'http://localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}'
This suggestion addresses both:
| new_url = f'http://localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}' | |
| assert isinstance(self.server, http.server.HTTPServer) | |
| new_url = f'http://redirect-user:redirect-secret@localhost:{self.server.server_port}/new/{INVENTORY_FILENAME}' |
| safe_url, | ||
| err.__class__, | ||
| str(err), | ||
| str(err).replace(inv_location, safe_url), |
There was a problem hiding this comment.
Exact-string replacement does not reliably redact the URL after Requests has normalized it. For example, a valid URL may encode ~ as %7E, so inv_location contains the password s%7Eecret, but Requests constructs the error using the equivalent normalized form s~ecret. The replacement therefore does not match, leaving the password in the error output.
For example, an HTTP 500 currently produces output equivalent to:
intersphinx inventory 'http://user@example.com/objects.inv' not fetchable:
500 Server Error for url: http://user:s~ecret@example.com/objects.inv
The first occurrence is redacted, but the normalized URL in the underlying Requests error still contains the password. After redacting the URL retained by the exception, both occurrences would be safe:
intersphinx inventory 'http://user@example.com/objects.inv' not fetchable:
500 Server Error for url: http://user@example.com/objects.inv
Could we redact the URL from err.request.url or err.response.url when available, while retaining the original URL as a fallback?
Fixes #14342.
When intersphinx fails to fetch an inventory or detects a redirect, the error and info messages include the raw URL with basic auth credentials. The
_get_safe_urlhelper already exists in this file (and is used for the initial "loading inventory" info message) but wasn't applied to the error path in_fetch_inventory_urlor the "inventory has moved" log message.