Compare commits

...

11 Commits

Author SHA1 Message Date
timothykim 7ba8921b24 add default server block to drop unknown host headers
Deploy nginx / deploy (push) Failing after 4s
Deploy nginx / test (push) Has been skipped
2026-03-20 16:12:06 -04:00
timothykim 33aef6553d fix tls tests to load system ca certificates 2026-03-20 16:07:32 -04:00
timothykim 10133867be add ssl/tls hardening to nginx
Deploy nginx / deploy (push) Successful in 3s
Deploy nginx / test (push) Failing after 6s
2026-03-20 16:05:41 -04:00
timothykim 4a7e2bf5dd update security.md with fixed and accepted items 2026-03-20 16:00:24 -04:00
timothykim 33a238308d fetch secrets by uuid instead of listing all
Deploy garage / deploy (push) Failing after 2s
2026-03-20 15:51:43 -04:00
timothykim 2099464245 remove deploy user from docker group 2026-03-20 15:38:11 -04:00
timothykim cce91c0aca restrict garage admin api to home ip
Deploy nginx / deploy (push) Successful in 3s
Deploy nginx / test (push) Successful in 6s
2026-03-20 15:33:36 -04:00
timothykim 9f9882cdda add testing section to readme 2026-03-20 15:22:58 -04:00
timothykim 493a42e98c test -> tests 2026-03-20 15:20:29 -04:00
timothykim 9a2918a1f4 fix test directory path in deploy-nginx workflow 2026-03-20 15:19:41 -04:00
timothykim cc07a34489 fix nginx security headers not being applied to https responses
Deploy nginx / deploy (push) Successful in 3s
Deploy nginx / test (push) Successful in 54s
nginx add_header inheritance meant X-Content-Type-Options and X-Frame-Options
were silently dropped from all https server blocks. moved all security headers
into a shared snippet (security-headers.inc) included per server block. added
pytest-based header verification that auto-discovers sites from conf files.
2026-03-20 15:11:56 -04:00
23 changed files with 619 additions and 20 deletions
+11
View File
@@ -16,3 +16,14 @@ jobs:
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key
ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
test:
needs: deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- run: pip install pytest
- run: pytest tests/ -v
+2 -1
View File
@@ -119,7 +119,8 @@ cert as SANs.
Services needing secrets declare them in `.env.keys`: Services needing secrets declare them in `.env.keys`:
``` ```
ENV_VAR=bws-secret-name # bws-secret-name
ENV_VAR=bws-secret-uuid
``` ```
During deploy, `deploy.sh`: During deploy, `deploy.sh`:
+19 -2
View File
@@ -43,6 +43,7 @@ docker/
Dockerfile Dockerfile
garage.toml garage.toml
.env.keys .env.keys
tests/ # pytest integration tests (run post-deploy)
.gitea/workflows/ # Per-app deploy workflows + provision.yml .gitea/workflows/ # Per-app deploy workflows + provision.yml
``` ```
@@ -160,6 +161,21 @@ docker exec garage /garage layout apply --version 1
If only hantim is reprovisioned, argento retains the layout and hantim If only hantim is reprovisioned, argento retains the layout and hantim
reconnects automatically. reconnects automatically.
## Testing
Tests live in `tests/` and run with pytest:
```bash
pip install pytest
pytest tests/ -v
```
Tests auto-discover sites from `docker/nginx/conf.d/*.conf` — no list to
maintain when adding new sites.
The `deploy-nginx.yml` workflow runs tests automatically after each nginx
deploy. If tests fail, Gitea sends an email notification.
## Secrets ## Secrets
### Bitwarden Secrets Manager ### Bitwarden Secrets Manager
@@ -184,8 +200,9 @@ Machine accounts: `hantim-server` (token at `/etc/bws-token`), `hantim-ci` (rese
### Service secrets (`.env.keys`) ### Service secrets (`.env.keys`)
Services that need secrets declare them in `.env.keys` (format: Services that need secrets declare them in `.env.keys` (format:
`ENV_VAR=bws-secret-name`, one per line). `deploy.sh` fetches each secret `ENV_VAR=bws-secret-uuid`, one per line, with `# secret-name` comments).
from bws and generates `.env` before starting the service. `deploy.sh` fetches each secret by UUID from bws and generates `.env`
before starting the service.
### Gitea org-level secrets ### Gitea org-level secrets
+407
View File
@@ -0,0 +1,407 @@
# Security Analysis
Vulnerability assessment of the hantim-server infrastructure. Each finding
includes severity, affected files, explanation, and a proposed fix. Sorted
from highest to lowest severity.
---
## HIGH
### ~~1. Security headers silently dropped on all HTTPS responses~~ FIXED
Fixed in `cc07a34`. All security headers moved to shared snippet
(`security-headers.inc`) included from each HTTPS server block.
Post-deploy test in `tests/test_security_headers.py` verifies all headers.
---
### ~~2. Garage admin API exposed to the internet with only token auth~~ FIXED
Fixed in `cce91c0`. Admin API restricted to home IP via nginx
`allow`/`deny`. Update IP if it changes: `dig +short argento.ddns.net`.
---
### ~~3. Docker group membership gives deploy user root-equivalent access~~ FIXED
Fixed in `2099464`. Removed `usermod -aG docker deploy` from
`bootstrap.sh`. Deploy user removed from docker group on live server.
---
### ~~4. All secrets fetched on every deploy (bws secret list in a loop)~~ FIXED
Fixed in `33a2383`. `.env.keys` now uses secret UUIDs. `deploy.sh` fetches
each secret individually via `bws secret get <uuid>`.
---
### 5. Garage RPC port open to the internet — ACCEPTED
**Affected:** `docker/garage/compose.yml:12`, `scripts/configure.sh:20`
Port 3901 is bound to the host and opened in the firewall for cluster
replication with argento. Firewalld cannot resolve DDNS hostnames
dynamically, and argento's IP changes with the home ISP. The RPC secret
(long random string from Bitwarden) is the primary protection.
**Risk accepted:** restricting by IP would break replication on IP change.
The RPC secret provides adequate protection. Downgraded from HIGH to
MEDIUM.
---
## MEDIUM
### 6. No SSL/TLS hardening in nginx
**Affected:** `docker/nginx/nginx.conf`, all `conf.d/*.conf`
No `ssl_protocols`, `ssl_ciphers`, `ssl_session_cache`, or OCSP stapling
is configured. The nginx:alpine image's defaults may allow TLS 1.0/1.1 and
weak cipher suites depending on the version.
Certbot writes `options-ssl-nginx.conf` to `/etc/letsencrypt/` (which is
mounted into the container), but no conf file includes it.
**Fix:** Add TLS hardening to `nginx.conf` inside the `http` block:
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
```
Or include certbot's config:
```nginx
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
```
---
### 7. No default server block to catch unknown Host headers
**Affected:** `docker/nginx/conf.d/`
Without a catch-all server block, requests with unknown `Host` headers are
served by whichever conf file nginx loads first (alphabetically). This
leaks which sites are hosted and can be used for reconnaissance.
**Fix:** Add a default server block:
```nginx
# docker/nginx/conf.d/00-default.conf
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
return 444;
}
```
The `444` status silently drops the connection.
---
### 8. No rate limiting on any endpoint
**Affected:** `docker/nginx/nginx.conf`, all `conf.d/*.conf`
No `limit_req` or `limit_conn` zones are defined. The server is vulnerable
to brute-force attacks against the S3 and admin APIs, and to connection
exhaustion / slowloris attacks.
**Fix:** Add rate limiting zones to `nginx.conf`:
```nginx
http {
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=2r/s;
...
}
```
Apply them in server blocks:
```nginx
# In s3.hantim.net.conf and garage.hantim.net.conf:
limit_req zone=api burst=20 nodelay;
# In site server blocks:
limit_req zone=general burst=30 nodelay;
```
---
### 9. envsubst replaces all environment variables in garage.toml
**Affected:** `docker/garage/Dockerfile:8`
```dockerfile
CMD envsubst < /etc/garage.toml.tpl > /etc/garage.toml && exec /garage server
```
`envsubst` without arguments replaces every `$VAR` or `${VAR}` pattern. If
any other environment variable (e.g., `$HOME`, `$PATH`) matches a pattern
in the template, it will be silently substituted. This is fragile and could
break the config in unexpected ways.
**Fix:** Specify the variables explicitly:
```dockerfile
CMD envsubst '${RPC_SECRET} ${ARGENTO_NODE_ID} ${ADMIN_TOKEN}' < /etc/garage.toml.tpl > /etc/garage.toml && exec /garage server
```
---
### 10. StrictHostKeyChecking=accept-new trusts first connection blindly
**Affected:** `.gitea/workflows/*.yml`, `tools/app.sh:367`
All SSH connections use `-o StrictHostKeyChecking=accept-new`, which trusts
whatever host key is presented on first contact. A man-in-the-middle during
the first CI run (or after server reprovisioning) could intercept the
deploy connection and capture the SSH private key.
**Fix:** Pin the server's host key in workflows. Add a step before SSH:
```yaml
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ vars.DEPLOY_HOST_KEY }}" >> ~/.ssh/known_hosts
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
```
Store the server's host public key as a Gitea variable (`DEPLOY_HOST_KEY`).
---
### 11. API tokens visible in process listings
**Affected:** `tools/app.sh` (lines 134, 139, 225, etc.)
Secrets passed as curl header arguments appear in `ps aux` and
`/proc/<pid>/cmdline` for the duration of the curl call:
```bash
VULTR_AUTH="Authorization: Bearer $VULTR_API_KEY"
curl -s "$VULTR_API/domains/$APP" -H "$VULTR_AUTH"
```
**Fix:** Use curl's `--header @-` or `-H @<file>` to read headers from
stdin/file instead of command arguments:
```bash
VULTR_AUTH_FILE=$(mktemp)
echo "Authorization: Bearer $VULTR_API_KEY" > "$VULTR_AUTH_FILE"
chmod 600 "$VULTR_AUTH_FILE"
trap "rm -f $VULTR_AUTH_FILE" EXIT
curl -s "$VULTR_API/domains/$APP" -H "@$VULTR_AUTH_FILE"
```
Or use a `.netrc` file for authentication.
---
### 12. No container resource limits
**Affected:** All `docker/*/compose.yml`
No memory or CPU limits are set on any container. A runaway container
(memory leak, fork bomb, or compromised process) can consume all host
resources and take down every service.
**Fix:** Add resource limits to compose files:
```yaml
services:
app:
...
deploy:
resources:
limits:
memory: 128M
cpus: '0.5'
```
For nginx and garage, use higher limits appropriate to their workload.
---
### 13. Brief nginx downtime during initial cert issuance
**Affected:** `scripts/deploy.sh:87-114`
When deploying nginx with new domains that need certs, the script stops
nginx, runs `certbot --standalone`, then starts nginx. During this window
(potentially minutes if multiple certs are needed), all sites are down.
**Fix:** Issue certs via the `cert-<domain>` SSH command (which uses
`--webroot` and causes zero downtime) before deploying the nginx config.
The `app.sh` already does this (`cmd_cert` runs before nginx gets the new
config). For the standalone path, consider using certbot's `--preferred-challenges dns`
or pre-issuing certs in a separate step.
---
### 14. Hardcoded Bitwarden secret UUIDs
**Affected:** `scripts/bootstrap.sh:21-22`
```bash
SECRET_REGISTRY_TOKEN="43f4a77d-7bb4-49ad-8044-b411016c5c6e"
SECRET_DEPLOY_PUBKEY="945760dd-90e2-46e5-98ad-b411016e3f3b"
```
UUIDs identify specific secrets. If this repo is exposed, an attacker
knows exactly which secret IDs to target. Using `bws secret list` with
name lookup (like the rest of the codebase does) is more consistent and
doesn't leak UUIDs.
**Fix:** Use name-based lookup like `deploy.sh` and `app.sh` do:
```bash
REGISTRY_TOKEN=$(bws secret list | jq -r '.[] | select(.key == "hantim-ci-registry-push") | .value')
DEPLOY_PUBKEY=$(bws secret list | jq -r '.[] | select(.key == "hantim-deploy-ssh-public-key") | .value')
```
---
### 15. certbot registers without email
**Affected:** `scripts/deploy.sh:29, 109`
```bash
certbot certonly --standalone --non-interactive --agree-tos \
--register-unsafely-without-email ...
```
Without an email, Let's Encrypt cannot send expiration warnings. If the
certbot renewal timer/cron breaks silently, certificates expire without
notice, causing site outages.
**Fix:** Register with an email:
```bash
certbot certonly --standalone --non-interactive --agree-tos \
-m admin@hantim.net ...
```
---
## LOW
### 16. Certbot renewal hook has no error handling
**Affected:** `scripts/configure.sh:26-30`
```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
#!/bin/bash
docker exec nginx nginx -s reload
HOOK
```
If the nginx container is down when certs renew, the reload fails silently.
The new cert won't take effect until nginx is manually restarted.
**Fix:**
```bash
#!/bin/bash
set -e
if ! docker exec nginx nginx -s reload; then
logger -t certbot "ERROR: Failed to reload nginx after cert renewal"
exit 1
fi
```
---
### 17. No audit logging for deploy operations
**Affected:** `scripts/deploy.sh`
There is no logging of who triggered a deploy, what changed, or when
secrets were accessed. If the server is compromised, there's no audit trail.
**Fix:** Add logging to deploy.sh:
```bash
logger -t hantim-deploy "deploy started: cmd=$CMD user=$(whoami) ssh_client=${SSH_CLIENT:-local}"
```
And at the end:
```bash
logger -t hantim-deploy "deploy finished: cmd=$CMD"
```
---
### 18. git pull without explicit branch
**Affected:** `scripts/deploy.sh:39, 51`
```bash
git pull
```
If the repo's default tracking branch changes or is misconfigured, `git
pull` could pull the wrong branch.
**Fix:**
```bash
git pull origin main
```
---
## Positive security practices already in place
- SSH `command=` restriction limits deploy user to specific commands
- Deploy user has `nologin` shell and scoped sudoers entry
- All scripts use `set -euo pipefail`
- HTTPS enforced everywhere with HTTP-to-HTTPS redirects
- HSTS with preload on all main server blocks
- `.env` files are `chmod 600` and gitignored
- BWS token file is `chmod 600`
- Docker network isolation: only nginx binds host ports
- Input validation on domain names with regex in `deploy.sh` and `app.sh`
- Idempotent scripts safe to re-run
- Certbot auto-renewal configured via timer or cron
- `nginx -t` config test before applying changes
---
## Recommended priority
1. ~~Fix security headers (item 1)~~ DONE
2. ~~Restrict Garage admin API access (item 2)~~ DONE
3. ~~Remove deploy user from docker group (item 3)~~ DONE
4. ~~Fix bws secret list loop (item 4)~~ DONE
5. ~~Garage RPC port (item 5)~~ ACCEPTED
6. Add SSL/TLS hardening (item 6)
7. Add default server block (item 7)
8. Everything else as time permits
+6 -3
View File
@@ -1,3 +1,6 @@
RPC_SECRET=hantim-garage-rpc-secret # hantim-garage-rpc-secret
ARGENTO_NODE_ID=hantim-garage-argento-node-id RPC_SECRET=076a4798-4180-4190-9212-b41200ff08e3
ADMIN_TOKEN=hantim-garage-admin-token # hantim-garage-argento-node-id
ARGENTO_NODE_ID=98454088-faf7-4d36-b98a-b41200ff52dd
# hantim-garage-admin-token
ADMIN_TOKEN=5565abc1-cd9f-451e-97a2-b412015dbb53
+19
View File
@@ -0,0 +1,19 @@
# Drop connections with unknown Host headers
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
return 444;
}
+6 -1
View File
@@ -22,9 +22,14 @@ server {
ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location / { location / {
# restrict to argento (home IP)
# update if IP changes: dig +short argento.ddns.net
allow 173.79.207.76;
deny all;
proxy_pass http://garage:3903; proxy_pass http://garage:3903;
} }
} }
+1 -1
View File
@@ -36,7 +36,7 @@ server {
ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;
+1 -1
View File
@@ -36,7 +36,7 @@ server {
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;
+1 -1
View File
@@ -36,7 +36,7 @@ server {
ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;
+1 -1
View File
@@ -22,7 +22,7 @@ server {
ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
client_max_body_size 100M; client_max_body_size 100M;
+5
View File
@@ -0,0 +1,5 @@
add_header Strict-Transport-Security "max-age=63072000; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
+1 -1
View File
@@ -36,7 +36,7 @@ server {
ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem; ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;
+1 -1
View File
@@ -36,7 +36,7 @@ server {
ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem; ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;
+10 -2
View File
@@ -13,8 +13,16 @@ http {
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log;
add_header X-Content-Type-Options nosniff; ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Frame-Options DENY; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
} }
-1
View File
@@ -95,7 +95,6 @@ if ! id deploy &>/dev/null; then
echo "==> Creating deploy user..." echo "==> Creating deploy user..."
useradd -r -s /usr/sbin/nologin deploy useradd -r -s /usr/sbin/nologin deploy
fi fi
usermod -aG docker deploy
echo "==> Setting up deploy SSH key..." echo "==> Setting up deploy SSH key..."
mkdir -p /home/deploy/.ssh mkdir -p /home/deploy/.ssh
+4 -3
View File
@@ -57,11 +57,12 @@ if [ -f "docker/$APP/.env.keys" ] && [ -f /etc/bws-token ]; then
env_content="" env_content=""
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
[ -z "$line" ] && continue [ -z "$line" ] && continue
[[ "$line" = \#* ]] && continue
var_name="${line%%=*}" var_name="${line%%=*}"
secret_key="${line#*=}" secret_id="${line#*=}"
value=$(bws secret list | jq -r --arg key "$secret_key" '.[] | select(.key == $key) | .value') value=$(bws secret get "$secret_id" | jq -r .value)
if [ -z "$value" ]; then if [ -z "$value" ]; then
echo "ERROR: Secret '$secret_key' not found in bws." echo "ERROR: Secret '$secret_id' not found in bws."
echo " Check that the secret exists and the machine account has access." echo " Check that the secret exists and the machine account has access."
exit 1 exit 1
fi fi
+29
View File
@@ -0,0 +1,29 @@
"""Test that unknown Host headers are dropped by the default server block."""
import socket
import ssl
import pytest
HOST = "www.hantim.net"
def test_unknown_host_https_dropped():
"""HTTPS request with a fake Host header should be dropped (connection reset)."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = socket.create_connection((HOST, 443))
ssock = ctx.wrap_socket(sock, server_hostname=HOST)
ssock.sendall(b"GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n")
resp = ssock.recv(4096)
assert resp == b"", f"Expected empty response, got {resp[:100]}"
def test_unknown_host_http_dropped():
"""HTTP request with a fake Host header should be dropped (connection reset)."""
sock = socket.create_connection((HOST, 80))
sock.sendall(b"GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n")
resp = sock.recv(4096)
assert resp == b"", f"Expected empty response, got {resp[:100]}"
+60
View File
@@ -0,0 +1,60 @@
"""Test that security headers are present on all HTTPS sites."""
import glob
import os
import re
import urllib.request
import ssl
import pytest
CONF_DIR = os.path.join(os.path.dirname(__file__), "..", "docker", "nginx", "conf.d")
def discover_sites():
"""Build HTTPS URLs from nginx conf files that include security-headers.inc."""
sites = []
for conf in sorted(glob.glob(os.path.join(CONF_DIR, "*.conf"))):
text = open(conf).read()
if "security-headers.inc" not in text:
continue
# Use the first server_name in the block that includes the headers
for block in re.split(r"(?=server\s*\{)", text):
if "security-headers.inc" in block:
m = re.search(r"server_name\s+([^;]+);", block)
if m:
sites.append(f"https://{m.group(1).split()[0]}")
break
return sites
SITES = discover_sites()
EXPECTED_HEADERS = {
"Strict-Transport-Security": "max-age=63072000; preload",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}
def get_headers(url):
ctx = ssl.create_default_context()
req = urllib.request.Request(url, method="HEAD")
try:
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
return dict(resp.headers)
except urllib.error.HTTPError as e:
return dict(e.headers)
@pytest.mark.parametrize("url", SITES, ids=lambda u: u.split("//")[1])
def test_security_headers(url):
headers = get_headers(url)
missing = []
for name, expected in EXPECTED_HEADERS.items():
actual = headers.get(name)
if actual != expected:
missing.append(f"{name}: expected '{expected}', got '{actual}'")
assert not missing, "\n".join(missing)
+34
View File
@@ -0,0 +1,34 @@
"""Test that TLS is properly hardened on all HTTPS sites."""
import socket
import ssl
import pytest
HOST = "www.hantim.net"
def test_tls13_works():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_default_certs()
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.maximum_version = ssl.TLSVersion.TLSv1_3
with ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST) as s:
assert s.version() == "TLSv1.3"
def test_tls12_works():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_default_certs()
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
with ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST) as s:
assert s.version() == "TLSv1.2"
def test_tls11_rejected():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.minimum_version = ssl.TLSVersion.TLSv1_1
ctx.maximum_version = ssl.TLSVersion.TLSv1_1
with pytest.raises((ssl.SSLError, OSError)):
ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST)
+1 -1
View File
@@ -339,7 +339,7 @@ server {
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem; ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always; include /etc/nginx/conf.d/security-headers.inc;
location /media/ { location /media/ {
proxy_pass http://garage:3902/; proxy_pass http://garage:3902/;