diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 8b03f0a8ed3..961d3cecfb7 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -228,7 +228,13 @@ class GenericCamera(Camera): try: stream_url = self._stream_source.async_render(parse_result=False) url = yarl.URL(stream_url) - if not url.user and not url.password and self._username and self._password: + if ( + not url.user + and not url.password + and self._username + and self._password + and url.is_absolute() + ): url = url.with_user(self._username).with_password(self._password) return str(url) except TemplateError as err: diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 9096f2ce87e..514264f919e 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -150,6 +150,12 @@ async def async_test_still( except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None + try: + yarl_url = yarl.URL(url) + except ValueError: + return {CONF_STILL_IMAGE_URL: "malformed_url"}, None + if not yarl_url.is_absolute(): + return {CONF_STILL_IMAGE_URL: "relative_url"}, None verify_ssl = info[CONF_VERIFY_SSL] auth = generate_auth(info) try: @@ -222,7 +228,12 @@ async def async_test_stream( if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True - url = yarl.URL(stream_source) + try: + url = yarl.URL(stream_source) + except ValueError: + return {CONF_STREAM_SOURCE: "malformed_url"} + if not url.is_absolute(): + return {CONF_STREAM_SOURCE: "relative_url"} if not url.user and not url.password: username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 7d3cab19aa5..608c85c1379 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -6,6 +6,8 @@ "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", @@ -75,6 +77,8 @@ "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]", "invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]", + "malformed_url": "[%key:component::generic::config::error::malformed_url%]", + "relative_url": "[%key:component::generic::config::error::relative_url%]", "template_error": "[%key:component::generic::config::error::template_error%]", "timeout": "[%key:component::generic::config::error::timeout%]", "stream_no_route_to_host": "[%key:component::generic::config::error::stream_no_route_to_host%]", diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index d01e6e59a4b..cb2200f9755 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -1,20 +1,19 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", @@ -50,14 +49,12 @@ "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", - "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", - "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", + "relative_url": "Relative URLs are not allowed", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index f0589301014..592d139f92e 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -180,33 +180,43 @@ async def test_form_only_still_sample(hass, user_flow, image_file): @respx.mock @pytest.mark.parametrize( - ("template", "url", "expected_result"), + ("template", "url", "expected_result", "expected_errors"), [ # Test we can handle templates in strange parts of the url, #70961. ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "http://{{example.org", "http://example.org", data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "template_error"}, ), ( "invalid1://invalid:4\\1", "invalid1://invalid:4%5c1", - data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "malformed_url"}, + ), + ( + "relative/urls/are/not/allowed.jpg", + "relative/urls/are/not/allowed.jpg", + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "relative_url"}, ), ], ) async def test_still_template( - hass, user_flow, fakeimgbytes_png, template, url, expected_result + hass, user_flow, fakeimgbytes_png, template, url, expected_result, expected_errors ) -> None: """Test we can handle various templates.""" respx.get(url).respond(stream=fakeimgbytes_png) @@ -220,6 +230,7 @@ async def test_still_template( ) await hass.async_block_till_done() assert result2["type"] == expected_result + assert result2.get("errors") == expected_errors @respx.mock @@ -514,8 +525,29 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream result4["flow_id"], user_input=data, ) - assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM - assert result5["errors"] == {"stream_source": "template_error"} + + assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result5["errors"] == {"stream_source": "template_error"} + + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result6["errors"] == {"stream_source": "relative_url"} + + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) + assert result7.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result7["errors"] == {"stream_source": "malformed_url"} async def test_slug(hass, caplog): @@ -528,6 +560,10 @@ async def test_slug(hass, caplog): assert result is None assert "Syntax error in" in caplog.text + result = slug(hass, "http://example.com:999999999999/stream") + assert result is None + assert "Syntax error in" in caplog.text + @respx.mock async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream):