vextoly/gitman

Fork of patx/gitman.

Harden Gitman nginx and isolate Gunicorn on a private socket

Commit ed122c5 · patx · 2026-11-03T00:26:32-05:00

Changeset
ed122c5fb76df0f2e4cb8173fb1005aa9ff4eb19
Parents
00c32ecf9f8de66d4520fdfb6708378d83b04a11

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md
new file mode 100644
index 0000000..460fe6f
--- /dev/null
+++ b/deploy/nginx/README.md
@@ -0,0 +1,113 @@
+# GitMan nginx and backend isolation
+
+Deployed November 3, 2026. These are the GitMan-specific production files; they
+must not replace the shared nginx.conf or Wail virtual host.
+
+| Repository file | VPS destination |
+| --- | --- |
+| gitman-http.conf | /etc/nginx/conf.d/gitman-hardening.conf |
+| gitman-cloudflare-realip.conf | /etc/nginx/snippets/gitman-cloudflare-realip.conf |
+| gitman-server.conf | /etc/nginx/snippets/gitman-server.conf |
+| gitman-site.conf | /etc/nginx/sites-available/gitman |
+| gitman.socket | /etc/systemd/system/gitman.socket |
+| zz-private-socket.conf | /etc/systemd/system/gitman.service.d/zz-private-socket.conf |
+
+The existing sites-enabled/gitman symlink is retained. The zz prefix deliberately
+sorts after rollback-data.conf, whose older ExecStart binds TCP port 8080. Keep
+that drop-in's active database/repository paths; do not revert to the inactive
+default data directory.
+
+## Backend isolation
+
+Systemd creates /run/gitman/gunicorn.sock with mode 0660 and owner git:www-data.
+Gunicorn inherits that socket from gitman.socket. nginx connects through it;
+Gunicorn has no TCP listener, including on loopback. SocketBindDeny=any also
+prevents the service and its children from explicitly binding IPv4/IPv6 ports.
+Outbound networking, including DNS for Pages domain checks, remains available.
+The socket is accessible only to its owner, nginx's group, and privileged users.
+
+Both gitman.service and gitman.socket are enabled for reboot. Gunicorn's explicit
+Unix bind is intentional: combining --bind fd://3 with systemd activation duplicates
+fd 3 in the installed Gunicorn's socket list and fails startup.
+
+## Request handling
+
+- Ordinary forms: 64 KiB maximum; body idle timeout 30 seconds; proxy read/send
+  idle timeouts 60 seconds; connect timeout 5 seconds.
+- Git: 257 MiB maximum request (256 MiB repository cap plus framing allowance),
+  body idle timeout 120 seconds, upstream read idle timeout 7200 seconds.
+- Settings/bundle/chunk uploads: 257 MiB maximum request; body/send idle timeout
+  120 seconds; upstream read idle timeout 7500 seconds. The legacy multipart
+  upload shares the settings URL, so that URL requires the larger allowance.
+  Application limits still enforce the actual 256 MiB bundle/repository cap.
+- Commit details, pull request details, and archives: 300-second upstream read
+  idle timeout for potentially expensive Git work.
+- nginx buffers uploads before forwarding them, with finite body and concurrency
+  limits. These timeouts measure inactivity, not total request duration.
+- Requests: 20/second/IP plus burst 80. Auth POSTs: 10/minute/IP plus burst 10.
+  Auth GETs do not consume the auth-specific bucket. Excess returns HTTP 429.
+- Active requests: 32/IP, 128/virtual host. Git additionally allows 4/IP and
+  8/virtual host; settings/imports allow 2/IP and 4/virtual host.
+- Forwarded headers are overwritten consistently for all upstream locations.
+  CF-Connecting-IP is trusted only from official Cloudflare address ranges;
+  arbitrary clients cannot choose their own rate-limit identity.
+- HSTS applies to gitman.io and its www redirect, without includeSubDomains or
+  preload. Wildcard Pages and custom domains retain their existing policy.
+- TLS 1.2/1.3 and the Certbot TLS configuration are preserved. nginx version
+  disclosure is disabled in the GitMan virtual hosts. Static assets get nosniff;
+  application CSP/frame policies remain in the application to preserve Pages.
+- GET, HEAD, and POST remain available. Unsupported methods return HTTP 405.
+- GitMan access logs use /var/log/nginx/gitman-access.log and omit query strings,
+  cookies, authorization headers, and request bodies. The existing nginx logrotate
+  wildcard covers this file.
+
+The Cloudflare list was checked against https://www.cloudflare.com/ips-v4 and
+https://www.cloudflare.com/ips-v6 at deployment. Recheck both when maintaining
+these files; do not replace this allowlist with trust for all addresses.
+
+## Deployment and rollback
+
+Back up nginx and the GitMan systemd units before installation. Install the files
+with root ownership and mode 0644, retaining the enabled vhost symlink. Run
+nginx -t, systemctl daemon-reload, and systemd-analyze verify gitman.service
+gitman.socket before activation. Changing the backend transport requires stopping
+and starting GitMan, then reloading nginx. Enable gitman.socket after successful
+checks. A normal nginx-only update needs a syntax check and graceful reload.
+
+Production backups:
+
+- /root/gitman-releases/before-nginx-hardening-20261103T051754Z/nginx-config.tar.gz
+  contains the original nginx configuration; baseline.json records protected
+  configuration hashes, service PIDs, and Pages response hashes.
+- /root/gitman-releases/before-private-socket-20261103T052303Z/config.tar.gz contains
+  nginx and the GitMan unit configuration immediately before socket isolation.
+
+For socket rollback, stop GitMan and gitman.socket, disable the new socket unit,
+restore the pre-socket nginx upstream snippet, remove zz-private-socket.conf and
+gitman.socket, daemon-reload, start GitMan, validate nginx, and reload nginx.
+This restores the former loopback-only TCP backend while retaining nginx limits.
+The deployment scripts performed this rollback automatically on failed checks.
+
+## Verified on the live VPS
+
+- nginx syntax and systemd unit verification; all relevant services active.
+- Public HTTPS, auth pages, authenticated profile/edit profile/notifications,
+  and older notification batches.
+- Exact before/after bodies and statuses for user Pages, project Pages, and
+  custom-domain Pages, including voldservices.com; no new HSTS on these hosts.
+- Real Git clone and fetch through nginx; Git protocol v2 and push auth challenge.
+- HTTP 413 for forms above 64 KiB and Git/import requests above 257 MiB, without
+  transmitting oversized bodies; 1 MiB transfers accepted at the nginx boundary.
+- A real 1 MiB chunk request reaches the app's CSRF rejection guard. No production
+  repository import or credential change was needed for these probes.
+- Repeated invalid-CSRF auth POSTs trigger nginx HTTP 429 even when the caller
+  varies forged Cloudflare and forwarded-IP headers; application password-failure
+  counters are not exercised by these probes.
+- Held requests enforce both per-IP and per-vhost Git/import concurrency caps.
+- Public Cloudflare traffic is attributed to a non-Cloudflare client address.
+- nginx's user can use the private socket; an unrelated local user cannot.
+- No Gunicorn TCP listener remains; port 8080 refuses local connections and an
+  external connection probe is blocked. A transient unit with the same cgroup
+  policy confirms explicit TCP binding is denied by the kernel.
+- Shared nginx.conf and Wail's configuration hashes are unchanged. Wail's process
+  was not restarted, and its HTTPS endpoint still returns HTTP 200.
diff --git a/deploy/nginx/gitman-cloudflare-realip.conf b/deploy/nginx/gitman-cloudflare-realip.conf
new file mode 100644
index 0000000..4553384
--- /dev/null
+++ b/deploy/nginx/gitman-cloudflare-realip.conf
@@ -0,0 +1,27 @@
+# Install as /etc/nginx/snippets/gitman-cloudflare-realip.conf.
+# Retrieved from https://www.cloudflare.com/ips-v4 and /ips-v6 on 2026-11-03.
+# Never trust loopback, all addresses, or a caller-supplied X-Forwarded-For here.
+set_real_ip_from 173.245.48.0/20;
+set_real_ip_from 103.21.244.0/22;
+set_real_ip_from 103.22.200.0/22;
+set_real_ip_from 103.31.4.0/22;
+set_real_ip_from 141.101.64.0/18;
+set_real_ip_from 108.162.192.0/18;
+set_real_ip_from 190.93.240.0/20;
+set_real_ip_from 188.114.96.0/20;
+set_real_ip_from 197.234.240.0/22;
+set_real_ip_from 198.41.128.0/17;
+set_real_ip_from 162.158.0.0/15;
+set_real_ip_from 104.16.0.0/13;
+set_real_ip_from 104.24.0.0/14;
+set_real_ip_from 172.64.0.0/13;
+set_real_ip_from 131.0.72.0/22;
+set_real_ip_from 2400:cb00::/32;
+set_real_ip_from 2606:4700::/32;
+set_real_ip_from 2803:f800::/32;
+set_real_ip_from 2405:b500::/32;
+set_real_ip_from 2405:8100::/32;
+set_real_ip_from 2a06:98c0::/29;
+set_real_ip_from 2c0f:f248::/32;
+real_ip_header CF-Connecting-IP;
+real_ip_recursive off;
diff --git a/deploy/nginx/gitman-http.conf b/deploy/nginx/gitman-http.conf
new file mode 100644
index 0000000..cec3295
--- /dev/null
+++ b/deploy/nginx/gitman-http.conf
@@ -0,0 +1,22 @@
+# Install as /etc/nginx/conf.d/gitman-hardening.conf (inside http {}).
+# These maps/zones only take effect in GitMan server blocks that reference them.
+map $host $gitman_hsts {
+    default "";
+    gitman.io "max-age=31536000";
+    www.gitman.io "max-age=31536000";
+}
+map "$request_method:$uri" $gitman_auth_key {
+    default "";
+    ~^POST:/(login|signup|settings/password)$ $binary_remote_addr;
+}
+limit_req_zone $binary_remote_addr zone=gitman_requests:10m rate=20r/s;
+limit_req_zone $gitman_auth_key zone=gitman_auth:10m rate=10r/m;
+limit_conn_zone $binary_remote_addr zone=gitman_clients:10m;
+limit_conn_zone $server_name zone=gitman_total:1m;
+limit_conn_zone $binary_remote_addr zone=gitman_git_clients:10m;
+limit_conn_zone $server_name zone=gitman_git_total:1m;
+limit_conn_zone $binary_remote_addr zone=gitman_import_clients:10m;
+limit_conn_zone $server_name zone=gitman_import_total:1m;
+log_format gitman_safe '$remote_addr [$time_local] host=$host '
+    '"$request_method $uri $server_protocol" $status $body_bytes_sent '
+    'request_time=$request_time upstream_time=$upstream_response_time';
diff --git a/deploy/nginx/gitman-server.conf b/deploy/nginx/gitman-server.conf
new file mode 100644
index 0000000..cc4c54c
--- /dev/null
+++ b/deploy/nginx/gitman-server.conf
@@ -0,0 +1,72 @@
+# Install as /etc/nginx/snippets/gitman-server.conf; HTTPS app servers only.
+include /etc/nginx/snippets/gitman-cloudflare-realip.conf;
+server_tokens off;
+client_max_body_size 64k;
+client_header_timeout 15s;
+client_body_timeout 30s;
+send_timeout 60s;
+keepalive_timeout 20s;
+keepalive_requests 500;
+reset_timedout_connection on;
+proxy_connect_timeout 5s;
+proxy_read_timeout 60s;
+proxy_send_timeout 60s;
+proxy_http_version 1.1;
+proxy_request_buffering on;
+proxy_next_upstream off;
+# Each location inherits this complete set. Do not partially override it.
+proxy_set_header Host $host;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $remote_addr;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_set_header X-Forwarded-Host $host;
+proxy_set_header X-Forwarded-Port $server_port;
+proxy_set_header X-Forwarded-Ssl "";
+proxy_set_header Forwarded "";
+proxy_set_header CF-Connecting-IP "";
+proxy_set_header Connection "";
+limit_req zone=gitman_requests burst=80 nodelay;
+limit_req zone=gitman_auth burst=10 nodelay;
+limit_req_status 429;
+limit_conn gitman_clients 32;
+limit_conn gitman_total 128;
+limit_conn_status 429;
+access_log /var/log/nginx/gitman-access.log gitman_safe;
+add_header Strict-Transport-Security $gitman_hsts always;
+# Keep application CSP/frame policies out of nginx: Pages has a different policy.
+if ($request_method !~ ^(GET|HEAD|POST)$) { return 405; }
+
+# Git pack plus framing allowance; align with the app's 256 MiB repository cap.
+location ^~ /git/ {
+    client_max_body_size 257m;
+    client_body_timeout 120s;
+    proxy_read_timeout 7200s;
+    proxy_send_timeout 120s;
+    proxy_buffering off;
+    limit_conn gitman_clients 32;
+    limit_conn gitman_total 128;
+    limit_conn gitman_git_clients 4;
+    limit_conn gitman_git_total 8;
+    proxy_pass http://unix:/run/gitman/gunicorn.sock;
+}
+# Legacy multipart bundle uploads share the settings endpoint with small forms.
+location ~ ^/[^/]+/[^/]+/settings(?:/import-bundle(?:/chunk)?)?$ {
+    client_max_body_size 257m;
+    client_body_timeout 120s;
+    proxy_read_timeout 7500s;
+    proxy_send_timeout 120s;
+    proxy_buffering off;
+    limit_conn gitman_clients 32;
+    limit_conn gitman_total 128;
+    limit_conn gitman_import_clients 2;
+    limit_conn gitman_import_total 4;
+    proxy_pass http://unix:/run/gitman/gunicorn.sock;
+}
+# Diff/archive generation can take longer on a repository near its quota.
+location ~ ^/[^/]+/[^/]+/(?:archive/|commits/[0-9a-fA-F]+$|pulls/[0-9]+$) {
+    proxy_read_timeout 300s;
+    proxy_pass http://unix:/run/gitman/gunicorn.sock;
+}
+location / {
+    proxy_pass http://unix:/run/gitman/gunicorn.sock;
+}
diff --git a/deploy/nginx/gitman-site.conf b/deploy/nginx/gitman-site.conf
new file mode 100644
index 0000000..103dc18
--- /dev/null
+++ b/deploy/nginx/gitman-site.conf
@@ -0,0 +1,65 @@
+# Install as /etc/nginx/sites-available/gitman; preserve its enabled symlink.
+server {
+    listen 80;
+    server_name gitman.io *.gitman.io;
+    server_tokens off;
+    client_max_body_size 64k;
+    client_header_timeout 15s;
+    return 301 https://$host$request_uri;
+}
+server {
+    listen 80;
+    server_name voldservices.com www.voldservices.com;
+    server_tokens off;
+    return 301 https://voldservices.com$request_uri;
+}
+server {
+    listen 443 ssl;
+    http2 on;
+    server_name www.voldservices.com;
+    server_tokens off;
+    ssl_certificate /etc/letsencrypt/live/voldservices.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/voldservices.com/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+    return 301 https://voldservices.com$request_uri;
+}
+server {
+    listen 443 ssl;
+    http2 on;
+    server_name voldservices.com;
+    ssl_certificate /etc/letsencrypt/live/voldservices.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/voldservices.com/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+    include /etc/nginx/snippets/gitman-server.conf;
+}
+server {
+    listen 443 ssl;
+    http2 on;
+    server_name www.gitman.io;
+    server_tokens off;
+    ssl_certificate /etc/letsencrypt/live/gitman.io/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/gitman.io/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+    add_header Strict-Transport-Security $gitman_hsts always;
+    return 301 https://gitman.io$request_uri;
+}
+server {
+    listen 443 ssl default_server;
+    http2 on;
+    server_name gitman.io *.gitman.io;
+    ssl_certificate /etc/letsencrypt/live/gitman.io/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/gitman.io/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+    location ^~ /static/ {
+        alias /opt/apps/gitman/static/;
+        add_header Strict-Transport-Security $gitman_hsts always;
+        add_header X-Content-Type-Options nosniff always;
+        limit_except GET { deny all; }
+    }
+    # Retain default routing for verified custom-domain Pages sites.
+    include /etc/nginx/snippets/gitman-server.conf;
+}
diff --git a/deploy/nginx/gitman.socket b/deploy/nginx/gitman.socket
new file mode 100644
index 0000000..7cd631e
--- /dev/null
+++ b/deploy/nginx/gitman.socket
@@ -0,0 +1,15 @@
+# Install as /etc/systemd/system/gitman.socket.
+[Unit]
+Description=Private GitMan socket for nginx
+
+[Socket]
+ListenStream=/run/gitman/gunicorn.sock
+SocketUser=git
+SocketGroup=www-data
+SocketMode=0660
+DirectoryMode=0755
+RemoveOnStop=yes
+Service=gitman.service
+
+[Install]
+WantedBy=sockets.target
diff --git a/deploy/nginx/zz-private-socket.conf b/deploy/nginx/zz-private-socket.conf
new file mode 100644
index 0000000..610a3fb
--- /dev/null
+++ b/deploy/nginx/zz-private-socket.conf
@@ -0,0 +1,14 @@
+# Install as /etc/systemd/system/gitman.service.d/zz-private-socket.conf.
+# Sort after rollback-data.conf so its old TCP ExecStart cannot override this.
+[Unit]
+Requires=gitman.socket
+After=gitman.socket
+
+[Service]
+Type=notify
+NotifyAccess=main
+TimeoutStartSec=60
+ExecStart=
+ExecStart=/opt/apps/gitman/.venv/bin/gunicorn --bind unix:/run/gitman/gunicorn.sock --workers 2 --worker-class gthread --threads 4 app:app
+# Defense in depth: Gunicorn may inherit the Unix socket, but cannot bind TCP/UDP.
+SocketBindDeny=any