diff --git a/.github/workflows/call-validation.yml b/.github/workflows/call-validation.yml index 05e395339b..79155629e2 100644 --- a/.github/workflows/call-validation.yml +++ b/.github/workflows/call-validation.yml @@ -109,12 +109,13 @@ jobs: PR_SCOPE: ${{ inputs.pr_scope }} run: ./scripts/meta/resolve/pr/branch_prefix.sh - - name: 🔀 Assert the base branch is fully merged into the PR head + - name: 🔀 Assert the event's base commit is merged into the PR head if: ${{ github.event_name == 'pull_request' && inputs.pr_scope != '' }} working-directory: pr-head env: INFINITO_MAIN_REMOTE: origin INFINITO_MAIN_BRANCH: ${{ github.event.pull_request.base.ref }} + INFINITO_MAIN_COMMIT: ${{ github.event.pull_request.base.sha }} run: bash "${GITHUB_WORKSPACE}/.base-tooling/scripts/git/assert/main_merged.sh" - name: 🔧 Set up Python diff --git a/group_vars/all/04_webserver.yml b/group_vars/all/04_webserver.yml index b0af51f9eb..5168689cb9 100644 --- a/group_vars/all/04_webserver.yml +++ b/group_vars/all/04_webserver.yml @@ -24,3 +24,13 @@ WEBSERVER_WORKER_CONNECTIONS: >- (lookup('applications') | resource_filter(application_id, 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT)) | int ] | min }} + +# Response security headers of every vhost; an app overrides any key under `security_headers` in its meta/server.yml. +WEBSERVER_SECURITY_HEADERS: + nosniff: false # X-Content-Type-Options: nosniff + referrer_policy: "" # Referrer-Policy, sent only when the upstream sends none; "" sends none + permissions_policy: "" # Permissions-Policy on HTML documents; "" sends none + hsts: + max_age: 31536000 # Strict-Transport-Security max-age in seconds + include_subdomains: false # adds includeSubDomains, which covers every subdomain of the vhost + preload: false # adds preload; requires include_subdomains and a max_age of one year diff --git a/plugins/filter/security_headers.py b/plugins/filter/security_headers.py new file mode 100644 index 0000000000..78af696964 --- /dev/null +++ b/plugins/filter/security_headers.py @@ -0,0 +1,10 @@ +"""Expose the response security headers of one application to templates.""" + +from __future__ import annotations + +from utils.roles.applications.security_headers import security_headers + + +class FilterModule: + def filters(self): + return {"security_headers": security_headers} diff --git a/roles/pkgmgr/meta/services.yml b/roles/pkgmgr/meta/services.yml index 079f44d0e8..ea3a8c7739 100644 --- a/roles/pkgmgr/meta/services.yml +++ b/roles/pkgmgr/meta/services.yml @@ -3,4 +3,4 @@ pkgmgr: lifecycle: beta repository: https://github.com/kevinveenbirkenbach/package-manager.git - ref: v2.0.4 + ref: v2.0.5 diff --git a/roles/sys-front-tls/templates/nginx_ssl_header.j2 b/roles/sys-front-tls/templates/nginx_ssl_header.j2 index 6fc4b4f1a7..00526b0318 100644 --- a/roles/sys-front-tls/templates/nginx_ssl_header.j2 +++ b/roles/sys-front-tls/templates/nginx_ssl_header.j2 @@ -11,7 +11,7 @@ ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets on; -add_header Strict-Transport-Security max-age=31536000 always; +add_header Strict-Transport-Security "{{ (lookup('applications') | security_headers(application_id, WEBSERVER_SECURITY_HEADERS)).hsts }}" always; ssl_stapling on; ssl_stapling_verify on; diff --git a/roles/sys-svc-proxy/README.md b/roles/sys-svc-proxy/README.md index 65120388ba..d284cf5588 100644 --- a/roles/sys-svc-proxy/README.md +++ b/roles/sys-svc-proxy/README.md @@ -31,11 +31,35 @@ The goal of this role is to deliver a **hassle-free, production-ready reverse pr - **Automatic TLS & HSTS**: integrates with the *sys-svc-webserver-https* role for certificate management. - **Flexible vHost templates**: *basic* and *ws_generic* flavours cover standard HTTP and WebSocket applications. -- **Security headers**: sensible defaults plus optional X-Frame-Options / CSP based on application settings. +- **Security headers**: CSP from each role's `meta/csp.yml`, plus the per-role headers described in [Security Headers](#security-headers). - **WebSocket & HTTP/2 aware**: upgrades, keep-alive tuning, and gzip already configured. - **OAuth2 gating**: drop-in support when *web-app-keycloak's SSO-proxy sidecar* is present. - **Modular includes**: headers, locations, and global snippets are factored for easy extension. +## Security Headers + +`WEBSERVER_SECURITY_HEADERS` in [04_webserver.yml](../../group_vars/all/04_webserver.yml) holds the platform values. A role overrides any key under `security_headers` in its `meta/server.yml`; an inventory overrides it at `applications..server.security_headers`. + +```yaml +# roles//meta/server.yml +security_headers: + nosniff: true + referrer_policy: strict-origin-when-cross-origin + permissions_policy: 'camera=(self "https://meet.example.org"), microphone=(self)' + hsts: + include_subdomains: true + preload: true +``` + +| Key | Header | Where it is rendered | +|---|---|---| +| `nosniff` | `X-Content-Type-Options: nosniff` | every response of a location that includes [content_security_policy.conf.j2](templates/headers/content_security_policy.conf.j2) | +| `referrer_policy` | `Referrer-Policy` | the same locations, only when the upstream sends none | +| `permissions_policy` | `Permissions-Policy` | HTML documents of the same locations | +| `hsts.*` | `Strict-Transport-Security` | the server block of every TLS vhost, via [nginx_ssl_header.j2](../sys-front-tls/templates/nginx_ssl_header.j2); an onion vhost has no TLS and gets none | + +The `security_headers` filter rejects unknown keys, wrong types, unknown `Referrer-Policy` tokens, and `preload` without `include_subdomains` or below a one-year `max_age`. + ## Credits Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**. diff --git a/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 b/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 index 80d3b05a73..e1aeca87e3 100644 --- a/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 +++ b/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 @@ -19,6 +19,8 @@ header_filter_by_lua_block { ngx.header["Content-Security-Policy-Report-Only"] = nil end +{% include 'roles/sys-svc-proxy/templates/headers/security_headers.lua.j2' %} + -- If you'll modify the body later, drop Content-Length on HTML if is_html then ngx.ctx.is_html = true diff --git a/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 b/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 new file mode 100644 index 0000000000..58dd96ed17 --- /dev/null +++ b/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 @@ -0,0 +1,14 @@ +{% set _security_headers = lookup('applications') | security_headers(application_id, WEBSERVER_SECURITY_HEADERS) %} +{% if _security_headers.nosniff %} + ngx.header["X-Content-Type-Options"] = "nosniff" +{% endif %} +{% if _security_headers.referrer_policy %} + if not ngx.header["Referrer-Policy"] then + ngx.header["Referrer-Policy"] = {{ _security_headers.referrer_policy | to_json }} + end +{% endif %} +{% if _security_headers.permissions_policy %} + if is_html then + ngx.header["Permissions-Policy"] = {{ _security_headers.permissions_policy | to_json }} + end +{% endif %} diff --git a/roles/web-app-bookwyrm/meta/services.yml b/roles/web-app-bookwyrm/meta/services.yml index fcee76585f..544d79cf7c 100644 --- a/roles/web-app-bookwyrm/meta/services.yml +++ b/roles/web-app-bookwyrm/meta/services.yml @@ -62,7 +62,7 @@ bookwyrm: version: 3.11-slim custom: true repository: https://github.com/bookwyrm-social/bookwyrm.git - ref: v0.9.3 + ref: v0.9.4 min_storage: 579MB registration_open: false allow_invite_request: false diff --git a/roles/web-app-gitlab/meta/services.yml b/roles/web-app-gitlab/meta/services.yml index 9c7cd70687..111272749a 100644 --- a/roles/web-app-gitlab/meta/services.yml +++ b/roles/web-app-gitlab/meta/services.yml @@ -76,7 +76,7 @@ gitlab: lifecycle: beta webservice: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-webservice healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -95,7 +95,7 @@ sidekiq: flavor: tcp path: liveness image: registry.gitlab.com/gitlab-org/build/cng/gitlab-sidekiq-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-sidekiq cpus: "1.0" mem_reservation: 2g @@ -106,7 +106,7 @@ sidekiq: http: 3808 workhorse: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-workhorse-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-workhorse healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -118,7 +118,7 @@ gitaly: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitaly - version: v19.5.1 + version: v19.5.3 healthcheck: test: ["CMD", "/scripts/healthcheck"] name: gitlab-gitaly @@ -131,7 +131,7 @@ gitaly: pids_limit: 1024 shell: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-shell - version: v19.5.1 + version: v19.5.3 name: gitlab-shell ports: internal: @@ -144,7 +144,7 @@ migrations: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitlab-rails-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-migrations cpus: "1.0" mem_reservation: 1g diff --git a/roles/web-app-pgadmin/meta/services.yml b/roles/web-app-pgadmin/meta/services.yml index 4184791bf4..263a62428e 100644 --- a/roles/web-app-pgadmin/meta/services.yml +++ b/roles/web-app-pgadmin/meta/services.yml @@ -11,7 +11,7 @@ pgadmin: # nocheck: default-placement-manager embedded sqlite config db; node-local in swarm placement: manager image: dpage/pgadmin4 - version: "9.18" + version: "9.19" min_storage: 378MB server_mode: false master_password_required: true diff --git a/roles/web-app-prometheus/meta/services.yml b/roles/web-app-prometheus/meta/services.yml index fd8f536f5b..3efbe50131 100644 --- a/roles/web-app-prometheus/meta/services.yml +++ b/roles/web-app-prometheus/meta/services.yml @@ -79,7 +79,7 @@ alertmanager: flavor: wget path: -/healthy image: prom/alertmanager # nocheck: volumes-path-single-spot: the repository path of an image name, not a mount target - version: v0.34.1 + version: v0.34.2 name: prometheus-alertmanager cpus: "0.25" mem_reservation: 64m diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index 2d1238bd0b..4f9f1999bd 100644 --- a/roles/web-app-socialhome/meta/services.yml +++ b/roles/web-app-socialhome/meta/services.yml @@ -13,7 +13,7 @@ socialhome: # nocheck: default-placement-manager embedded sqlite in a node-local volume; no clustered storage placement: manager image: ghcr.io/social-home-io/socialhome - version: "2026.10.27" + version: "2026.10.29" ports: local: http: 8089 diff --git a/roles/web-app-wazuh/meta/services.yml b/roles/web-app-wazuh/meta/services.yml index d38e9054c9..224974902e 100644 --- a/roles/web-app-wazuh/meta/services.yml +++ b/roles/web-app-wazuh/meta/services.yml @@ -60,7 +60,7 @@ wazuh: lifecycle: beta certs_generator: image: wazuh/wazuh-certs-generator - version: "0.0.4" + version: "0.0.5" name: wazuh-certs-generator cpus: "0.2" mem_reservation: 64m diff --git a/scripts/git/assert/main_merged.sh b/scripts/git/assert/main_merged.sh index bb63e43b46..ac7471884d 100755 --- a/scripts/git/assert/main_merged.sh +++ b/scripts/git/assert/main_merged.sh @@ -4,23 +4,37 @@ # from the current tip, so a branch can never be pushed while it lags main. # # Env overrides: INFINITO_MAIN_REMOTE (default origin), INFINITO_MAIN_BRANCH -# (default main). +# (default main), INFINITO_MAIN_COMMIT (unset: check the live branch tip; set: +# fetch and check this full commit SHA of that branch instead, e.g. the base +# SHA a CI event carries). set -euo pipefail remote="${INFINITO_MAIN_REMOTE:-origin}" # nocheck: git maintainer tooling knob, not a deployment env key branch="${INFINITO_MAIN_BRANCH:-main}" # nocheck: git maintainer tooling knob, not a deployment env key +if [[ -v INFINITO_MAIN_COMMIT ]]; then + target="${INFINITO_MAIN_COMMIT}" # nocheck: git maintainer tooling knob, not a deployment env key + if [[ ! "$target" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; then + echo "❌ assert-main-merged: INFINITO_MAIN_COMMIT must be a full commit SHA, got '${target}'." >&2 + exit 2 + fi + label="${remote}/${branch}@${target}" +else + target="$branch" + label="${remote}/${branch}" +fi + cd "$(git rev-parse --show-toplevel)" -echo "assert-main-merged: fetching ${remote}/${branch} ..." >&2 -git fetch --quiet "$remote" "$branch" +echo "assert-main-merged: fetching ${label} ..." >&2 +git fetch --quiet "$remote" "$target" if git merge-base --is-ancestor FETCH_HEAD HEAD; then - echo "✅ assert-main-merged: ${remote}/${branch} is fully merged into HEAD." >&2 + echo "✅ assert-main-merged: ${label} is fully merged into HEAD." >&2 exit 0 fi behind="$(git rev-list --count HEAD..FETCH_HEAD)" -echo "❌ assert-main-merged: ${remote}/${branch} has ${behind} commit(s) not merged into HEAD." >&2 +echo "❌ assert-main-merged: ${label} has ${behind} commit(s) not merged into HEAD." >&2 echo " Merge upstream main first, e.g.: git pull ${remote} ${branch}" >&2 exit 1 diff --git a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py index 16c42ed62b..7395b5f012 100644 --- a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py +++ b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py @@ -71,10 +71,16 @@ class TestWorktreeSwarmLabSubnet(unittest.TestCase): with self.subTest(slot=slot): subnet = self._subnet(slot) clashes = sorted( - name for name, network in self.reserved.items() if subnet.overlaps(network) + name + for name, network in self.reserved.items() + if subnet.overlaps(network) + ) + self.assertEqual( + clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}" + ) + self.assertNotIn( + subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}" ) - self.assertEqual(clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}") - self.assertNotIn(subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}") seen[subnet] = slot diff --git a/tests/unit/python/plugins/filter/test_security_headers.py b/tests/unit/python/plugins/filter/test_security_headers.py new file mode 100644 index 0000000000..797f9e5f88 --- /dev/null +++ b/tests/unit/python/plugins/filter/test_security_headers.py @@ -0,0 +1,153 @@ +import copy +import unittest + +from plugins.filter.security_headers import FilterModule +from utils.cache.yaml import load_yaml +from utils.roles.applications.security_headers import security_headers + +from . import PROJECT_ROOT + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + + +def apps_with(override): + """Return an applications map whose one app declares *override*. + + Args: + override: the app's ``server.security_headers`` block. + """ + return {"web-app-x": {"server": {"security_headers": override}}} + + +class TestPlatformValues(unittest.TestCase): + def test_an_app_without_a_block_gets_the_platform_values(self): + apps = {"web-app-plain": {"server": {"status_codes": {"default": [200]}}}} + self.assertEqual( + security_headers(apps, "web-app-plain", PLATFORM), + { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": "max-age=31536000", + }, + ) + + def test_a_redirect_vhost_passes_its_domain_and_gets_the_platform_values(self): + result = security_headers(apps_with({"nosniff": True}), "old.example", PLATFORM) + self.assertFalse(result["nosniff"]) + self.assertEqual(result["hsts"], "max-age=31536000") + + def test_the_repository_platform_values_are_valid(self): + platform = load_yaml(PROJECT_ROOT / "group_vars/all/04_webserver.yml")[ + "WEBSERVER_SECURITY_HEADERS" + ] + self.assertEqual( + security_headers({}, "web-app-plain", platform)["hsts"], + "max-age=31536000", + ) + + def test_an_incomplete_platform_block_fails(self): + platform = copy.deepcopy(PLATFORM) + del platform["hsts"]["preload"] + with self.assertRaisesRegex(ValueError, "preload"): + security_headers({}, "web-app-plain", platform) + + def test_the_platform_block_is_not_mutated(self): + platform = copy.deepcopy(PLATFORM) + security_headers( + apps_with({"hsts": {"include_subdomains": True}}), "web-app-x", platform + ) + self.assertEqual(platform, PLATFORM) + + +class TestOverrides(unittest.TestCase): + def test_an_override_replaces_only_its_own_keys(self): + result = security_headers( + apps_with({"nosniff": True, "hsts": {"include_subdomains": True}}), + "web-app-x", + PLATFORM, + ) + self.assertTrue(result["nosniff"]) + self.assertEqual(result["referrer_policy"], "") + self.assertEqual(result["hsts"], "max-age=31536000; includeSubDomains") + + def test_preload_follows_include_subdomains(self): + result = security_headers( + apps_with({"hsts": {"include_subdomains": True, "preload": True}}), + "web-app-x", + PLATFORM, + ) + self.assertEqual(result["hsts"], "max-age=31536000; includeSubDomains; preload") + + def test_a_referrer_policy_list_is_accepted(self): + value = "no-referrer, strict-origin-when-cross-origin" + result = security_headers( + apps_with({"referrer_policy": value}), "web-app-x", PLATFORM + ) + self.assertEqual(result["referrer_policy"], value) + + def test_a_permissions_policy_keeps_its_quoted_origins(self): + value = 'camera=(self "https://meet.example.org"), microphone=(self)' + result = security_headers( + apps_with({"permissions_policy": value}), "web-app-x", PLATFORM + ) + self.assertEqual(result["permissions_policy"], value) + + +class TestRejections(unittest.TestCase): + def assert_rejected(self, override, pattern, error=ValueError): + with self.assertRaisesRegex(error, pattern): + security_headers(apps_with(override), "web-app-x", PLATFORM) + + def test_an_unknown_key_fails(self): + self.assert_rejected({"nosnif": True}, "unknown keys") + + def test_an_unknown_hsts_key_fails(self): + self.assert_rejected({"hsts": {"subdomains": True}}, "unknown keys") + + def test_a_non_mapping_block_fails(self): + self.assert_rejected(None, "must be a mapping", TypeError) + + def test_a_string_flag_fails(self): + self.assert_rejected({"nosniff": "yes"}, "must be bool", TypeError) + + def test_a_boolean_max_age_fails(self): + self.assert_rejected({"hsts": {"max_age": True}}, "must be int", TypeError) + + def test_a_negative_max_age_fails(self): + self.assert_rejected({"hsts": {"max_age": -1}}, "negative") + + def test_preload_without_include_subdomains_fails(self): + self.assert_rejected({"hsts": {"preload": True}}, "requires include_subdomains") + + def test_preload_with_a_short_max_age_fails(self): + self.assert_rejected( + {"hsts": {"max_age": 86400, "include_subdomains": True, "preload": True}}, + "at least 31536000", + ) + + def test_an_unknown_referrer_policy_token_fails(self): + self.assert_rejected( + {"referrer_policy": "strict-origin-when-cross-orgin"}, + "unknown policy tokens", + ) + + def test_a_line_break_in_a_header_value_fails(self): + self.assert_rejected( + {"permissions_policy": "camera=()\r\nSet-Cookie: x=1"}, + "printable ASCII", + ) + + +class TestFilterModule(unittest.TestCase): + def test_filter_is_registered(self): + self.assertIs(FilterModule().filters()["security_headers"], security_headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/sys-front-tls/__init__.py b/tests/unit/python/roles/sys-front-tls/__init__.py new file mode 100644 index 0000000000..8ee16d7567 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/python/roles/sys-front-tls/templates/__init__.py b/tests/unit/python/roles/sys-front-tls/templates/__init__.py new file mode 100644 index 0000000000..087c384b57 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/templates/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[6] + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py b/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py new file mode 100644 index 0000000000..851cbed1b7 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import re +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from plugins.filter.security_headers import FilterModule +from utils.tls_common import resolve_enabled + +from . import PROJECT_ROOT + +TEMPLATE = "roles/sys-front-tls/templates/nginx_ssl_header.j2" + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + +PRIMARY_DOMAINS = { + "web-app-x": "x.example.org", + "web-app-onion": "x.abcdefghijklmnop.onion", + "old.example.org": "old.example.org", +} + + +def render(application_id: str, override: dict) -> str: + """Return the TLS server header of one vhost. + + Args: + application_id: the app the vhost belongs to, or a redirect domain. + override: the app's ``server.security_headers`` block. + """ + applications = { + app: {"server": {"security_headers": override}} + for app in ("web-app-x", "web-app-onion") + } + + def lookup(kind, *args, **_kw): + if kind == "applications": + return applications + if kind == "tls": + term, want = args + enabled = resolve_enabled( + applications.get(term, {}), True, primary_domain=PRIMARY_DOMAINS[term] + ) + return {"enabled": enabled, "ports.web": 443 if enabled else 80}[want] + if kind == "cert": + return f"/etc/certs/{args[0]}/{args[1]}" + raise AssertionError(f"unexpected lookup {kind!r}") + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters.update(FilterModule().filters()) + env.filters["bool"] = bool + return env.get_template(TEMPLATE).render( + application_id=application_id, + lookup=lookup, + WEBSERVER_SECURITY_HEADERS=PLATFORM, + ) + + +def hsts(rendered: str) -> list[str]: + """Return every Strict-Transport-Security value the header adds. + + Args: + rendered: the rendered TLS server header. + """ + return re.findall( + r'^add_header Strict-Transport-Security "([^"]*)" always;$', + rendered, + re.MULTILINE, + ) + + +class TestHsts(unittest.TestCase): + def test_the_platform_value_is_one_year_without_subdomains(self): + self.assertEqual(hsts(render("web-app-x", {})), ["max-age=31536000"]) + + def test_include_subdomains_and_preload_are_opt_in_per_app(self): + rendered = render( + "web-app-x", {"hsts": {"include_subdomains": True, "preload": True}} + ) + self.assertEqual( + hsts(rendered), ["max-age=31536000; includeSubDomains; preload"] + ) + + def test_a_redirect_vhost_sends_the_platform_value(self): + self.assertEqual(hsts(render("old.example.org", {})), ["max-age=31536000"]) + + def test_an_onion_vhost_sends_no_hsts_even_when_opted_in(self): + rendered = render("web-app-onion", {"hsts": {"include_subdomains": True}}) + self.assertNotIn("Strict-Transport-Security", rendered) + self.assertNotIn("ssl", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py b/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py new file mode 100644 index 0000000000..3baee51efc --- /dev/null +++ b/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py @@ -0,0 +1,117 @@ +"""Exception: a location with its own ``add_header`` drops every server-level one, +so these headers live in the Lua header filter of ``content_security_policy.conf.j2``. +""" + +from __future__ import annotations + +import json +import re +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from plugins.filter.security_headers import FilterModule +from utils.cache.files import read_text + +from . import PROJECT_ROOT + +SNIPPET = "roles/sys-svc-proxy/templates/headers/security_headers.lua.j2" +CSP_TEMPLATE = "roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2" + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + + +def render(override: dict) -> str: + """Return the Lua snippet for an app declaring *override*. + + Args: + override: the app's ``server.security_headers`` block. + """ + applications = {"web-app-x": {"server": {"security_headers": override}}} + + def lookup(kind, *_args, **_kw): + if kind == "applications": + return applications + raise AssertionError(f"unexpected lookup {kind!r}") + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters.update(FilterModule().filters()) + env.filters["to_json"] = json.dumps + return env.get_template(SNIPPET).render( + application_id="web-app-x", + lookup=lookup, + WEBSERVER_SECURITY_HEADERS=PLATFORM, + ) + + +def assignment(rendered: str, header: str) -> str: + """Return the Lua literal assigned to *header*, or an empty string. + + Args: + rendered: the rendered snippet. + header: the response header name. + """ + match = re.search( + rf'ngx\.header\["{re.escape(header)}"\] = (".*")$', rendered, re.MULTILINE + ) + return match.group(1) if match else "" + + +class TestSecurityHeadersSnippet(unittest.TestCase): + def test_the_platform_values_send_nothing(self): + self.assertNotIn("ngx.header", render({})) + + def test_nosniff_is_sent_when_opted_in(self): + rendered = render({"nosniff": True}) + self.assertEqual( + json.loads(assignment(rendered, "X-Content-Type-Options")), "nosniff" + ) + + def test_the_referrer_policy_never_replaces_the_upstream_one(self): + rendered = render({"referrer_policy": "strict-origin-when-cross-origin"}) + self.assertRegex( + rendered, + r'if not ngx\.header\["Referrer-Policy"\] then\s+' + r'ngx\.header\["Referrer-Policy"\] = "strict-origin-when-cross-origin"\s+end', + ) + + def test_the_permissions_policy_is_sent_on_documents_only(self): + rendered = render({"permissions_policy": "camera=(self)"}) + self.assertRegex( + rendered, + r'if is_html then\s+ngx\.header\["Permissions-Policy"\] = "camera=\(self\)"\s+end', + ) + + def test_quoted_origins_stay_one_lua_string(self): + value = 'camera=(self "https://meet.example.org"), microphone=(self)' + rendered = render({"permissions_policy": value}) + self.assertEqual(json.loads(assignment(rendered, "Permissions-Policy")), value) + + def test_hsts_is_left_to_the_server_level_header(self): + rendered = render({"hsts": {"include_subdomains": True}}) + self.assertNotIn("Strict-Transport-Security", rendered) + + +class TestSnippetPlacement(unittest.TestCase): + def test_the_snippet_runs_inside_the_csp_header_filter_after_is_html(self): + text = read_text(str(PROJECT_ROOT / CSP_TEMPLATE)) + block = text.index("header_filter_by_lua_block {") + is_html = text.index("local is_html") + include = text.index(f"{{% include '{SNIPPET}' %}}") + block_end = text.index("\n}\n", block) + self.assertLess(block, is_html) + self.assertLess(is_html, include) + self.assertLess(include, block_end) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/scripts/git/__init__.py b/tests/unit/python/scripts/git/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/__init__.py b/tests/unit/python/scripts/git/assert/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/test_main_merged.py b/tests/unit/python/scripts/git/assert/test_main_merged.py new file mode 100644 index 0000000000..5a41470cb1 --- /dev/null +++ b/tests/unit/python/scripts/git/assert/test_main_merged.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from utils.cache.files import PROJECT_ROOT + +SCRIPT = PROJECT_ROOT / "scripts" / "git" / "assert" / "main_merged.sh" +MAIN_COMMIT_ENV = "INFINITO_MAIN_COMMIT" # nocheck: git maintainer tooling knob, not a deployment env key + + +class MainMergedFixture: + """A bare ``origin`` whose ``main`` moved on after ``head`` branched off it. + + Args: + tmp: directory that holds the bare remote and its two clones. + """ + + def __init__(self, tmp: str) -> None: + root = Path(tmp) + inherited = { + k: v + for k, v in os.environ.items() + if not k.startswith(("GIT_", "INFINITO_")) + } + self.env = { + **inherited, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + } + origin = root / "origin.git" + maintainer = root / "maintainer" + self.head = root / "head" + self.git(root, "init", "--bare", "-b", "main", str(origin)) + self.git(root, "init", "-b", "main", str(maintainer)) + self.git(maintainer, "remote", "add", "origin", str(origin)) + self.base = self.commit(maintainer, "base") + self.git(maintainer, "push", "origin", "main") + self.git(root, "clone", str(origin), str(self.head)) + self.git(self.head, "switch", "-c", "feature") + self.commit(self.head, "feature") + self.main_tip = self.commit(maintainer, "main moved on") + self.git(maintainer, "push", "origin", "main") + + def git(self, cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + env=self.env, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + def commit(self, repo: Path, message: str) -> str: + self.git(repo, "commit", "--allow-empty", "-m", message) + return self.git(repo, "rev-parse", "HEAD") + + def merge_main(self) -> None: + self.git(self.head, "pull", "--no-rebase", "--no-edit", "origin", "main") + + def run_script(self, main_commit: str | None) -> subprocess.CompletedProcess: + """Run the script in ``head``. + + Args: + main_commit: value of the main-commit override; ``None`` leaves it unset. + """ + env = dict(self.env) + if main_commit is not None: + env[MAIN_COMMIT_ENV] = main_commit + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=self.head, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +class TestMainMergedPinnedCommit(unittest.TestCase): + def test_contained_commit_passes_after_main_moved_on(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"origin/main@{fixture.base} is fully merged", result.stderr) + + def test_commit_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.main_tip) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + f"origin/main@{fixture.main_tip} has 1 commit(s) not merged into HEAD", + result.stderr, + ) + + def test_empty_commit_fails_loudly(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script("") + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("must be a full commit SHA", result.stderr) + + +class TestMainMergedLiveTip(unittest.TestCase): + def test_live_tip_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(None) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + "origin/main has 1 commit(s) not merged into HEAD", result.stderr + ) + + def test_merged_live_tip_passes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + fixture.merge_main() + result = fixture.run_script(None) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("origin/main is fully merged into HEAD", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/roles/applications/security_headers.py b/utils/roles/applications/security_headers.py new file mode 100644 index 0000000000..f350bcc626 --- /dev/null +++ b/utils/roles/applications/security_headers.py @@ -0,0 +1,168 @@ +"""Response security headers the front proxy adds for one application. + +``WEBSERVER_SECURITY_HEADERS`` holds the platform values. An application +overrides any of them under ``security_headers`` in its ``meta/server.yml``. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from utils.roles.applications.config import get + +HSTS_PRELOAD_MIN_MAX_AGE = 31536000 + +REFERRER_POLICY_TOKENS = frozenset( + { + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + } +) + +SCHEMA: dict[str, Any] = { + "nosniff": bool, + "referrer_policy": str, + "permissions_policy": str, + "hsts": { + "max_age": int, + "include_subdomains": bool, + "preload": bool, + }, +} + +_FIELD_VALUE = re.compile(r"[\x20-\x7e]*") + + +def _check_type(value: Any, expected: type, where: str) -> None: + if expected is int: + valid = isinstance(value, int) and not isinstance(value, bool) + else: + valid = isinstance(value, expected) + if not valid: + raise TypeError( + f"{where} must be {expected.__name__}, got {type(value).__name__} {value!r}" + ) + + +def _resolve( + schema: Mapping[str, Any], base: Any, override: Any, where: str +) -> dict[str, Any]: + """Validate *override* against *schema* and lay it over *base*. + + Args: + schema: the expected keys and their types. + base: the complete mapping the override applies to, ``None`` when + *override* is the complete mapping itself. + override: the mapping to validate; partial when *base* is given. + where: dotted origin of *override*, for error messages. + + Returns: + A complete mapping with every key of *schema*. + """ + if not isinstance(override, Mapping): + raise TypeError(f"{where} must be a mapping, got {override!r}") + unknown = sorted(set(override) - set(schema)) + if unknown: + raise ValueError(f"{where} has unknown keys {unknown}, known: {sorted(schema)}") + if base is None: + missing = sorted(set(schema) - set(override)) + if missing: + raise ValueError(f"{where} lacks the keys {missing}") + + resolved: dict[str, Any] = {} + for key, expected in schema.items(): + path = f"{where}.{key}" + if key not in override: + resolved[key] = base[key] + elif isinstance(expected, Mapping): + resolved[key] = _resolve( + expected, None if base is None else base[key], override[key], path + ) + else: + _check_type(override[key], expected, path) + resolved[key] = override[key] + return resolved + + +def _field_value(value: str, where: str) -> str: + if not _FIELD_VALUE.fullmatch(value): + raise ValueError(f"{where} must be printable ASCII, got {value!r}") + return value + + +def _referrer_policy(value: str, where: str) -> str: + _field_value(value, where) + if not value: + return value + unknown = [ + token + for token in (part.strip() for part in value.split(",")) + if token not in REFERRER_POLICY_TOKENS + ] + if unknown: + raise ValueError(f"{where} has unknown policy tokens {unknown}") + return value + + +def _hsts(hsts: Mapping[str, Any], where: str) -> str: + if hsts["max_age"] < 0: + raise ValueError(f"{where}.max_age must not be negative") + if hsts["preload"] and not hsts["include_subdomains"]: + raise ValueError(f"{where}.preload requires include_subdomains") + if hsts["preload"] and hsts["max_age"] < HSTS_PRELOAD_MIN_MAX_AGE: + raise ValueError( + f"{where}.preload requires a max_age of at least {HSTS_PRELOAD_MIN_MAX_AGE}" + ) + directives = [f"max-age={hsts['max_age']}"] + if hsts["include_subdomains"]: + directives.append("includeSubDomains") + if hsts["preload"]: + directives.append("preload") + return "; ".join(directives) + + +def security_headers( + applications: Mapping[str, Any], application_id: str, platform: Mapping[str, Any] +) -> dict[str, Any]: + """Return the security header values the proxy sends for *application_id*. + + Args: + applications: the applications SPOT. + application_id: the role whose vhost is rendered; a redirect vhost + passes its domain, which owns no override. + platform: ``WEBSERVER_SECURITY_HEADERS``. + + Returns: + ``nosniff`` as a bool, ``referrer_policy`` and ``permissions_policy`` + as header values (empty when not sent) and ``hsts`` as the complete + ``Strict-Transport-Security`` value. + """ + base = _resolve(SCHEMA, None, platform, "WEBSERVER_SECURITY_HEADERS") + where = f"applications[{application_id!r}].server.security_headers" + override = get( + applications, + application_id, + "server.security_headers", + strict=False, + default={}, + skip_missing_app=True, + ) + resolved = _resolve(SCHEMA, base, override, where) + return { + "nosniff": resolved["nosniff"], + "referrer_policy": _referrer_policy( + resolved["referrer_policy"], f"{where}.referrer_policy" + ), + "permissions_policy": _field_value( + resolved["permissions_policy"], f"{where}.permissions_policy" + ), + "hsts": _hsts(resolved["hsts"], f"{where}.hsts"), + }