diff --git a/crates/policy/policies/client_registration.rego b/crates/policy/policies/client_registration.rego index 1a9a9541e..e0b46ef76 100644 --- a/crates/policy/policies/client_registration.rego +++ b/crates/policy/policies/client_registration.rego @@ -8,9 +8,24 @@ allow { count(violation) == 0 } +parse_uri(url) = obj { + is_string(url) + [matches] := regex.find_all_string_submatch_n("^(?P[a-z][a-z0-9+.-]*):(?://(?P((?:(?:[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])\\.)*(?:[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])|127.0.0.1|\\[::1\\])(?::(?P[0-9]+))?))?(?P/[A-Za-z0-9/.-]*)$", url, 1) + obj := {"scheme": matches[1], "authority": matches[2], "host": matches[3], "port": matches[4], "path": matches[5]} +} + secure_url(x) { - is_string(x) - startswith(x, "https://") + url := parse_uri(x) + url.scheme == "https" + url.host != "127.0.0.1" + url.host != "[::1]" + url.port == "" +} + +host_matches_client_uri(x) { + client_uri := parse_uri(input.client_metadata.client_uri) + uri := parse_uri(x) + uri.host == client_uri.host } violation[{"msg": "missing client_uri"}] { @@ -18,25 +33,46 @@ violation[{"msg": "missing client_uri"}] { } violation[{"msg": "invalid client_uri"}] { + not data.client_registration.allow_insecure_uris not secure_url(input.client_metadata.client_uri) } -violation[{"msg": "missing tos_uri"}] { - not input.client_metadata.tos_uri -} - violation[{"msg": "invalid tos_uri"}] { + input.client_metadata.tos_uri + not data.client_registration.allow_insecure_uris not secure_url(input.client_metadata.tos_uri) } -violation[{"msg": "missing policy_uri"}] { - not input.client_metadata.policy_uri +violation[{"msg": "tos_uri not on the same domain as the client_uri"}] { + input.client_metadata.tos_uri + not data.client_registration.allow_host_mismatch + not host_matches_client_uri(input.client_metadata.tos_uri) } violation[{"msg": "invalid policy_uri"}] { + input.client_metadata.policy_uri + not data.client_registration.allow_insecure_uris not secure_url(input.client_metadata.policy_uri) } +violation[{"msg": "policy_uri not on the same domain as the client_uri"}] { + input.client_metadata.policy_uri + not data.client_registration.allow_host_mismatch + not host_matches_client_uri(input.client_metadata.policy_uri) +} + +violation[{"msg": "invalid logo_uri"}] { + input.client_metadata.logo_uri + not data.client_registration.allow_insecure_uris + not secure_url(input.client_metadata.logo_uri) +} + +violation[{"msg": "logo_uri not on the same domain as the client_uri"}] { + input.client_metadata.logo_uri + not data.client_registration.allow_host_mismatch + not host_matches_client_uri(input.client_metadata.logo_uri) +} + violation[{"msg": "missing redirect_uris"}] { not input.client_metadata.redirect_uris } @@ -49,7 +85,76 @@ violation[{"msg": "empty redirect_uris"}] { count(input.client_metadata.redirect_uris) == 0 } -# violation[{"msg": "invalid redirect_uri"}] { -# some redirect_uri in input.client_metadata.redirect_uris -# not secure_url(redirect_uri) -# } +violation[{"msg": "invalid redirect_uri", "redirect_uri": redirect_uri}] { + # For 'web' apps, we should verify that redirect_uris are secure + input.client_metadata.application_type != "native" + some redirect_uri in input.client_metadata.redirect_uris + not data.client_registration.allow_host_mismatch + not host_matches_client_uri(redirect_uri) +} + +violation[{"msg": "invalid redirect_uri"}] { + # For 'web' apps, we should verify that redirect_uris are secure + input.client_metadata.application_type != "native" + some redirect_uri in input.client_metadata.redirect_uris + not data.client_registration.allow_insecure_uris + not secure_url(redirect_uri) +} + +# Used to verify that a reverse-dns formatted scheme is a strict subdomain of +# another host. +# This is used so a redirect_uri like 'com.example.app:/' works for +# a 'client_uri' of 'https://example.com/' +reverse_dns_match(host, reverse_dns) { + is_string(host) + is_string(reverse_dns) + + # Reverse the host + host_parts := array.reverse(split(host, ".")) + + # Split the already reversed DNS + dns_parts := split(reverse_dns, ".") + + # Check that the reverse_dns strictly is a subdomain of the host + array.slice(dns_parts, 0, count(host_parts)) == host_parts +} + +valid_native_redirector(x) { + url := parse_uri(x) + is_localhost(url.host) + url.scheme == "http" +} + +is_localhost(host) { + host == "localhost" +} + +is_localhost(host) { + host == "127.0.0.1" +} + +is_localhost(host) { + host == "[::1]" +} + +# Custom schemes should match the client_uri, reverse-dns style +# e.g. io.element.app:/ matches https://app.element.io/ +valid_native_redirector(x) { + url := parse_uri(x) + url.scheme != "http" + url.scheme != "https" + + # They should have no host/port + url.authority == "" + client_uri := parse_uri(input.client_metadata.client_uri) + reverse_dns_match(client_uri.host, url.scheme) +} + +violation[{"msg": "invalid redirect_uri"}] { + # For 'native' apps, we need to check that the redirect_uri is either + # a custom scheme, or localhost + # TODO: this might not be right, because of app-associated domains on mobile? + input.client_metadata.application_type == "native" + some redirect_uri in input.client_metadata.redirect_uris + not valid_native_redirector(redirect_uri) +} diff --git a/crates/policy/policies/client_registration_test.rego b/crates/policy/policies/client_registration_test.rego index 2042ca8ea..308ec4d0b 100644 --- a/crates/policy/policies/client_registration_test.rego +++ b/crates/policy/policies/client_registration_test.rego @@ -2,26 +2,272 @@ package client_registration test_valid { allow with input.client_metadata as { - "client_uri": "https://example.com", - "tos_uri": "https://example.com/tos", - "policy_uri": "https://example.com/policy", + "client_uri": "https://example.com/", "redirect_uris": ["https://example.com/callback"], } } test_missing_client_uri { - not allow with input.client_metadata as { - "tos_uri": "https://example.com/tos", - "policy_uri": "https://example.com/policy", - "redirect_uris": ["https://example.com/callback"], - } + not allow with input.client_metadata as {"redirect_uris": ["https://example.com/callback"]} } test_insecure_client_uri { not allow with input.client_metadata as { - "client_uri": "http://example.com", - "tos_uri": "https://example.com/tos", - "policy_uri": "https://example.com/policy", + "client_uri": "http://example.com/", "redirect_uris": ["https://example.com/callback"], } } + +test_tos_uri { + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "tos_uri": "https://example.com/tos", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "tos_uri": "http://example.com/tos", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "tos_uri": "http://example.com/tos", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_insecure_uris as true + + # Host mistmatch + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "tos_uri": "https://example.org/tos", + "redirect_uris": ["https://example.com/callback"], + } + + # Host mistmatch, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "tos_uri": "https://example.org/tos", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_host_mismatch as true +} + +test_logo_uri { + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "logo_uri": "https://example.com/logo.png", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "logo_uri": "http://example.com/logo.png", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "logo_uri": "http://example.com/logo.png", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_insecure_uris as true + + # Host mistmatch + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "logo_uri": "https://example.org/logo.png", + "redirect_uris": ["https://example.com/callback"], + } + + # Host mistmatch, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "logo_uri": "https://example.org/logo.png", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_host_mismatch as true +} + +test_policy_uri { + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "policy_uri": "https://example.com/policy", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "policy_uri": "http://example.com/policy", + "redirect_uris": ["https://example.com/callback"], + } + + # Insecure, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "policy_uri": "http://example.com/policy", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_insecure_uris as true + + # Host mistmatch + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "policy_uri": "https://example.org/policy", + "redirect_uris": ["https://example.com/callback"], + } + + # Host mistmatch, but allowed by the config + allow with input.client_metadata as { + "client_uri": "https://example.com/", + "policy_uri": "https://example.org/policy", + "redirect_uris": ["https://example.com/callback"], + } + with data.client_registration.allow_host_mismatch as true +} + +test_redirect_uris { + # Missing redirect_uris + not allow with input.client_metadata as {"client_uri": "https://example.com/"} + + # redirect_uris is not an array + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "redirect_uris": "https://example.com/callback", + } + + # Empty redirect_uris + not allow with input.client_metadata as { + "client_uri": "https://example.com/", + "redirect_uris": [], + } +} + +test_web_redirect_uri { + allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/second/callback", "https://example.com/callback"], + } + + # Insecure URL + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["http://example.com/callback", "https://example.com/callback"], + } + + # Insecure URL, but allowed by the config + allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["http://example.com/callback", "https://example.com/callback"], + } + with data.client_registration.allow_insecure_uris as true + + # Host mismatch + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/second/callback", "https://example.org/callback"], + } + + # Host mismatch, but allowed by the config + allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/second/callback", "https://example.org/callback"], + } + with data.client_registration.allow_host_mismatch as true + + # No custom scheme allowed + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["com.example.app:/callback"], + } + + # localhost not allowed + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["http://locahost:1234/callback"], + } + + # localhost not allowed + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["http://127.0.0.1:1234/callback"], + } + + # localhost not allowed + not allow with input.client_metadata as { + "application_type": "web", + "client_uri": "https://example.com/", + "redirect_uris": ["http://[::1]:1234/callback"], + } +} + +test_native_redirect_uri { + # This has all the redirect URIs types we're supporting for native apps + allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": [ + "com.example.app:/callback", + "http://localhost/callback", + "http://localhost:1234/callback", + "http://127.0.0.1/callback", + "http://127.0.0.1:1234/callback", + "http://[::1]/callback", + "http://[::1]:1234/callback", + ], + } + + # We don't allow HTTP URLs other than localhost + not allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": ["https://example.com/"], + } + + not allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": ["http://example.com/"], + } + + # We don't allow HTTPS on localhost + not allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": ["https://localhost:1234/"], + } + + # Ensure we're not allowing localhost as a prefix + not allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": ["http://localhost.com/"], + } + + # For custom schemes, it should match the client_uri hostname + not allow with input.client_metadata as { + "application_type": "native", + "client_uri": "https://example.com/", + "redirect_uris": ["org.example.app:/callback"], + } +} + +test_reverse_dns_match { + client_uri := parse_uri("https://element.io/") + redirect_uri := parse_uri("io.element.app:/callback") + reverse_dns_match(client_uri.host, redirect_uri.scheme) +}