Do not embed the WASM-compiled policies in the binary

This commit is contained in:
Quentin Gliech
2022-11-18 19:28:16 +01:00
parent 9d97e4a0e8
commit 44d397b54c
20 changed files with 124 additions and 92 deletions

3
policies/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/policy.wasm
/bundle.tar.gz
/coverage.json

41
policies/Makefile Normal file
View File

@@ -0,0 +1,41 @@
# Set to 1 to run OPA through Docker
DOCKER := 0
OPA_DOCKER_IMAGE := docker.io/openpolicyagent/opa:0.45.0
ifeq ($(DOCKER), 0)
OPA := opa
OPA_RW := opa
else
OPA := docker run -i -v $(shell pwd):/policies:ro -w /policies --rm $(OPA_DOCKER_IMAGE)
OPA_RW := docker run -i -v $(shell pwd):/policies -w /policies --rm $(OPA_DOCKER_IMAGE)
endif
policy.wasm: client_registration.rego register.rego authorization_grant.rego
$(OPA_RW) build -t wasm \
-e "client_registration/violation" \
-e "register/violation" \
-e "authorization_grant/violation" \
$^
tar xzf bundle.tar.gz /policy.wasm
$(RM) bundle.tar.gz
touch $@
.PHONY: fmt
fmt:
$(OPA_RW) fmt -w .
.PHONY: test
test:
$(OPA) test -v ./*.rego
.PHONY: coverage
coverage:
$(OPA) test --coverage ./*.rego | $(OPA) eval --format pretty \
--stdin-input \
--data util/coveralls.rego \
data.coveralls.from_opa > coverage.json
.PHONY: lint
lint:
$(OPA) fmt -d --fail .
$(OPA) check --strict .

View File

@@ -0,0 +1,38 @@
package authorization_grant
import future.keywords.in
default allow := false
allow {
count(violation) == 0
}
# Special case to make empty scope work
allowed_scope("") = true
allowed_scope("openid") = true
allowed_scope("email") = true
allowed_scope("urn:synapse:admin:*") {
some user in data.admin_users
input.user.username == user
}
allowed_scope(scope) {
regex.match("urn:matrix:org.matrix.msc2967.client:device:[A-Za-z0-9-]{10,}", scope)
}
allowed_scope("urn:matrix:org.matrix.msc2967.client:api:*") = true
violation[{"msg": msg}] {
some scope in split(input.authorization_grant.scope, " ")
not allowed_scope(scope)
msg := sprintf("scope '%s' not allowed", [scope])
}
violation[{"msg": "only one device scope is allowed at a time"}] {
scope_list := split(input.authorization_grant.scope, " ")
count({key | scope_list[key]; startswith(scope_list[key], "urn:matrix:org.matrix.msc2967.client:device:")}) > 1
}

View File

@@ -0,0 +1,63 @@
package authorization_grant
user := {"username": "john"}
test_standard_scopes {
allow with input.user as user
with input.authorization_grant as {"scope": "openid"}
allow with input.user as user
with input.authorization_grant as {"scope": "email"}
allow with input.user as user
with input.authorization_grant as {"scope": "openid email"}
# Not supported yet
not allow with input.user as user
with input.authorization_grant as {"scope": "phone"}
# Not supported yet
not allow with input.user as user
with input.authorization_grant as {"scope": "profile"}
}
test_matrix_scopes {
allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:api:*"}
}
test_device_scopes {
allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AAbbCCdd01"}
allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AAbbCCdd01-asdasdsa1-2313"}
# Invalid characters
not allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AABB:CCDDEE"}
not allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AABB*CCDDEE"}
not allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AABB!CCDDEE"}
# Too short
not allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:abcd"}
# Multiple device scope
not allow with input.user as user
with input.authorization_grant as {"scope": "urn:matrix:org.matrix.msc2967.client:device:AAbbCCdd01 urn:matrix:org.matrix.msc2967.client:device:AAbbCCdd02"}
}
test_synapse_admin_scopes {
allow with input.user as user
with data.admin_users as ["john"]
with input.authorization_grant as {"scope": "urn:synapse:admin:*"}
not allow with input.user as user
with data.admin_users as []
with input.authorization_grant as {"scope": "urn:synapse:admin:*"}
}

View File

@@ -0,0 +1,180 @@
package client_registration
import future.keywords.in
default allow := false
allow {
count(violation) == 0
}
parse_uri(url) = obj {
is_string(url)
[matches] := regex.find_all_string_submatch_n("^(?P<scheme>[a-z][a-z0-9+.-]*):(?://(?P<host>((?:(?:[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|0.0.0.0|\\[::1\\])(?::(?P<port>[0-9]+))?))?(?P<path>/[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) {
url := parse_uri(x)
url.scheme == "https"
# Disallow localhost variants
url.host != "localhost"
url.host != "127.0.0.1"
url.host != "0.0.0.0"
url.host != "[::1]"
# Must be standard port for HTTPS
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"}] {
not data.client_registration.allow_missing_client_uri
not input.client_metadata.client_uri
}
violation[{"msg": "invalid client_uri"}] {
not data.client_registration.allow_insecure_uris
not secure_url(input.client_metadata.client_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": "tos_uri not on the same host 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 host 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 host 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 contacts"}] {
not data.client_registration.allow_missing_contacts
not input.client_metadata.contacts
}
violation[{"msg": "invalid contacts"}] {
not is_array(input.client_metadata.contacts)
}
violation[{"msg": "empty contacts"}] {
count(input.client_metadata.contacts) == 0
}
violation[{"msg": "missing redirect_uris"}] {
not input.client_metadata.redirect_uris
}
violation[{"msg": "invalid redirect_uris"}] {
not is_array(input.client_metadata.redirect_uris)
}
violation[{"msg": "empty redirect_uris"}] {
count(input.client_metadata.redirect_uris) == 0
}
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)
}

View File

@@ -0,0 +1,348 @@
package client_registration
test_valid {
allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": ["https://example.com/callback"],
"contacts": ["contact@example.com"],
}
}
test_missing_client_uri {
not allow with input.client_metadata as {
"redirect_uris": ["https://example.com/callback"],
"contacts": ["contact@example.com"],
}
allow with input.client_metadata as {
"redirect_uris": ["https://example.com/callback"],
"contacts": ["contact@example.com"],
}
with data.client_registration.allow_missing_client_uri as true
}
test_insecure_client_uri {
not allow with input.client_metadata as {
"client_uri": "http://example.com/",
"redirect_uris": ["https://example.com/callback"],
"contacts": ["contact@example.com"],
}
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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/",
"contacts": ["contact@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",
"contacts": ["contact@example.com"],
}
# Empty redirect_uris
not allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": [],
"contacts": ["contact@example.com"],
}
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
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"],
"contacts": ["contact@example.com"],
}
# localhost not allowed
not allow with input.client_metadata as {
"application_type": "web",
"client_uri": "https://example.com/",
"redirect_uris": ["http://locahost:1234/callback"],
"contacts": ["contact@example.com"],
}
# 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"],
"contacts": ["contact@example.com"],
}
# localhost not allowed
not allow with input.client_metadata as {
"application_type": "web",
"client_uri": "https://example.com/",
"redirect_uris": ["http://[::1]:1234/callback"],
"contacts": ["contact@example.com"],
}
}
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",
],
"contacts": ["contact@example.com"],
}
# 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/"],
"contacts": ["contact@example.com"],
}
not allow with input.client_metadata as {
"application_type": "native",
"client_uri": "https://example.com/",
"redirect_uris": ["http://example.com/"],
"contacts": ["contact@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/"],
"contacts": ["contact@example.com"],
}
# 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/"],
"contacts": ["contact@example.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"],
"contacts": ["contact@example.com"],
}
}
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)
}
test_contacts {
# Missing contacts
not allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": ["https://example.com/callback"],
}
# Missing contacts, but allowed by config
allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": ["https://example.com/callback"],
}
with data.client_registration.allow_missing_contacts as true
# contacts is not an array
not allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": ["https://example.com/callback"],
"contacts": "contact@example.com",
}
# Empty contacts
not allow with input.client_metadata as {
"client_uri": "https://example.com/",
"redirect_uris": ["https://example.com/callback"],
"contacts": [],
}
}

60
policies/register.rego Normal file
View File

@@ -0,0 +1,60 @@
package register
import future.keywords.in
default allow := false
allow {
count(violation) == 0
}
violation[{"field": "username", "msg": "username too short"}] {
count(input.user.username) <= 2
}
violation[{"field": "username", "msg": "username too long"}] {
count(input.user.username) >= 15
}
violation[{"field": "password", "msg": msg}] {
count(input.user.password) < data.passwords.min_length
msg := sprintf("needs to be at least %d characters", [data.passwords.min_length])
}
violation[{"field": "password", "msg": "requires at least one number"}] {
data.passwords.require_number
not regex.match("[0-9]", input.user.password)
}
violation[{"field": "password", "msg": "requires at least one lowercase letter"}] {
data.passwords.require_lowercase
not regex.match("[a-z]", input.user.password)
}
violation[{"field": "password", "msg": "requires at least one uppercase letter"}] {
data.passwords.require_uppercase
not regex.match("[A-Z]", input.user.password)
}
# Allow any domains if the data.allowed_domains array is not set
email_domain_allowed {
not data.allowed_domains
}
# Allow an email only if its domain is in the list of allowed domains
email_domain_allowed {
[_, domain] := split(input.user.email, "@")
some allowed_domain in data.allowed_domains
glob.match(allowed_domain, ["."], domain)
}
violation[{"field": "email", "msg": "email domain not allowed"}] {
not email_domain_allowed
}
# Deny emails with their domain in the domains banlist
violation[{"field": "email", "msg": "email domain not allowed"}] {
[_, domain] := split(input.user.email, "@")
some banned_domain in data.banned_domains
glob.match(banned_domain, ["."], domain)
}

View File

@@ -0,0 +1,72 @@
package register
mock_user := {"username": "hello", "password": "Hunter2", "email": "hello@staging.element.io"}
test_allow_all_domains {
allow with input.user as mock_user
}
test_allowed_domain {
allow with input.user as mock_user
with data.allowed_domains as ["*.element.io"]
}
test_not_allowed_domain {
not allow with input.user as mock_user
with data.allowed_domains as ["example.com"]
}
test_banned_domain {
not allow with input.user as mock_user
with data.banned_domains as ["*.element.io"]
}
test_banned_subdomain {
not allow with input.user as mock_user
with data.allowed_domains as ["*.element.io"]
with data.banned_domains as ["staging.element.io"]
}
test_short_username {
not allow with input.user as {"username": "a", "email": "hello@element.io"}
}
test_long_username {
not allow with input.user as {"username": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "email": "hello@element.io"}
}
test_password_require_number {
allow with input.user as mock_user
with data.passwords.require_number as true
not allow with input.user as mock_user
with input.user.password as "hunter"
with data.passwords.require_number as true
}
test_password_require_lowercase {
allow with input.user as mock_user
with data.passwords.require_lowercase as true
not allow with input.user as mock_user
with input.user.password as "HUNTER2"
with data.passwords.require_lowercase as true
}
test_password_require_uppercase {
allow with input.user as mock_user
with data.passwords.require_uppercase as true
not allow with input.user as mock_user
with input.user.password as "hunter2"
with data.passwords.require_uppercase as true
}
test_password_min_length {
allow with input.user as mock_user
with data.passwords.min_length as 6
not allow with input.user as mock_user
with input.user.password as "short"
with data.passwords.min_length as 6
}

View File

@@ -0,0 +1,51 @@
package coveralls
import future.keywords
from_opa := {"source_files": coverage}
coverage contains obj if {
some file, report in input.files
obj := {"name": file, "coverage": to_lines(report)}
}
covered_map(report) = cm if {
covered := object.get(report, "covered", [])
cm := {line: 1 |
some item in covered
some line in numbers.range(item.start.row, item.end.row)
}
}
not_covered_map(report) = ncm if {
not_covered := object.get(report, "not_covered", [])
ncm := {line: 0 |
some item in not_covered
some line in numbers.range(item.start.row, item.end.row)
}
}
to_lines(report) = lines if {
cm := covered_map(report)
ncm := not_covered_map(report)
keys := sort([line | some line, _ in object.union(cm, ncm)])
last := keys[count(keys) - 1]
lines := [value |
some i in numbers.range(1, last)
value := to_value(cm, ncm, i)
]
}
to_value(cm, _, line) = 1 if {
cm[line]
}
to_value(_, ncm, line) = 0 if {
ncm[line]
}
to_value(cm, ncm, line) = null if {
not cm[line]
not ncm[line]
}