Merge branch 'main' into update/docker-image-versions-web-app-pretix-20261029-df38fa2

This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-10-29 23:55:16 +01:00
commit ab2dd4979e
25 files changed
+787 -23

No files matched your search

+2 -1
View File
@@ -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
+10
View File
@@ -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
+10
View File
@@ -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}
+1 -1
View File
@@ -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;
+25 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+19 -5
View File
@@ -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"),
}