mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Merge branch 'main' into update/docker-image-versions-web-app-pretix-20261029-df38fa2
This commit is contained in:
25 files changed
+787
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -3,4 +3,4 @@
|
||||
pkgmgr:
|
||||
lifecycle: beta
|
||||
repository: https://github.com/kevinveenbirkenbach/package-manager.git
|
||||
ref: v2.0.4
|
||||
ref: v2.0.5
|
||||
@@ -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;
|
||||
|
||||
@@ -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.<role>.server.security_headers`.
|
||||
|
||||
```yaml
|
||||
# roles/<role>/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)**.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[5]
|
||||
@@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
|
||||
|
||||
__all__ = ["PROJECT_ROOT"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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()
|
||||
@@ -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"),
|
||||
}
|
||||
Reference in new issue
Block a user