Hardening Gitea and Forgejo Self-Hosted Git Instances
Problem
Gitea and its community fork Forgejo have grown rapidly as self-hosted alternatives to GitHub and GitLab, particularly in air-gapped environments, regulated industries, and organisations reducing SaaS dependency. By mid-2025, Gitea reported over 40,000 production deployments tracked via the optional instance statistics endpoint.
This growth has brought increased security research attention — and a corresponding increase in disclosed vulnerabilities:
CVE-2024-6886 (Gitea ≤1.22.0): stored XSS in repository topic rendering that allowed an attacker to inject JavaScript into the admin UI, enabling session hijacking for users with admin accounts who viewed the topic. CVSS 8.2.
CVE-2024-24789 (Gitea ≤1.21.4): SSRF via the Git repository import/mirror functionality. An attacker with repository creation rights could cause Gitea to make HTTP requests to internal network services, including cloud metadata endpoints (169.254.169.254), potentially exfiltrating IAM credentials.
CVE-2023-1935 (Gitea ≤1.19.0): authentication bypass in the OAuth2 PKCE flow that could allow an attacker to obtain an OAuth token for any user by manipulating the state parameter.
CVE-2022-30781 (Gitea ≤1.16.8): path traversal in the file upload handler allowing arbitrary file write as the Gitea process user.
Forgejo CVE-2024-0740: similar SSRF vector via webhook URL validation, allowing internal network scanning from the Forgejo instance.
Beyond specific CVEs, self-hosted Gitea/Forgejo deployments exhibit consistent structural weaknesses:
Default open registration. Out-of-the-box, Gitea allows anyone on the network to create an account. In many deployments, this is never changed, meaning any internal network actor can create repositories, configure webhooks, and trigger CI pipelines.
Webhook SSRF. Gitea webhooks deliver events by making HTTP requests to configured URLs. Without an allowlist, any user with webhook creation rights can configure a webhook pointing to internal services — including cloud metadata APIs, internal Kubernetes API servers, or private registries — and trigger it by pushing to a repository.
Runner credential exposure. Gitea Actions runners use a registration token to join the runner pool. If this token is leaked (via public repository, CI log, or misconfigured secret), an attacker can register a malicious runner and intercept jobs.
Missing or misconfigured OAuth2 restrictions. Many Gitea deployments expose OAuth2 application creation to all users, allowing users to create OAuth2 apps that can request elevated permissions from other users.
Exposed admin API. The Gitea admin API (used for user management, hook management, and instance configuration) is accessible to any authenticated admin token holder. Tokens are often long-lived and widely shared.
Target systems: Gitea ≤1.23 and Forgejo ≤9.0 on Linux (bare metal, VM, Docker, Kubernetes); any self-hosted deployment accessible to more than one team; deployments in cloud environments with access to instance metadata services.
Threat Model
Adversary 1 — Internal attacker with user account. Access level: authenticated Gitea/Forgejo user account. Objective: create a webhook pointing to http://169.254.169.254/latest/meta-data/ to extract cloud IAM credentials via SSRF; alternatively, exploit stored XSS to hijack an admin session.
Adversary 2 — External attacker via open registration. Access level: network access to Gitea instance with open registration enabled. Objective: create an account, configure a malicious runner, push code to trigger CI pipelines that run on the internal runner pool, and use CI context to reach internal services.
Adversary 3 — Supply chain attack via repository mirror. Access level: ability to influence the content of a repository being mirrored. Objective: inject malicious code or pipeline configuration into a mirrored repository that gets executed by internal CI runners.
Adversary 4 — Credential theft via exposed token. Access level: read access to CI logs or public repository. Objective: extract Gitea runner registration token or admin API token from logs, register a malicious runner, and intercept job secrets.
Without hardening: SSRF via webhooks, open registration, and exposed tokens make a Gitea/Forgejo instance a lateral movement hub. With hardening: webhook allowlists, registration restrictions, and network-level controls constrain the attack surface to the application layer.
Configuration / Implementation
Step 1 — Upgrade to latest release
# Check current version
gitea --version
# or
forgejo --version
# As of 2026-05-12, minimum recommended:
# Gitea: 1.22.3+ (patches all 2024-2025 CVEs above)
# Forgejo: 9.0.3+
# Docker upgrade
docker pull gitea/gitea:latest
docker stop gitea && docker rm gitea
# Restart with existing volume mounts (data persists)
# Binary upgrade
wget https://github.com/go-gitea/gitea/releases/latest/download/gitea-linux-amd64
chmod +x gitea-linux-amd64
systemctl stop gitea
mv gitea-linux-amd64 /usr/local/bin/gitea
systemctl start gitea
Step 2 — Restrict registration and initial access
# /etc/gitea/app.ini (or /data/gitea/conf/app.ini in Docker)
[service]
; Disable open registration — require admin to create accounts
DISABLE_REGISTRATION = true
; Alternatively, require email domain restriction:
; EMAIL_DOMAIN_ALLOWLIST = example.com,corp.example.com
; Require email confirmation for new accounts
REGISTER_EMAIL_CONFIRM = true
; Disable username changes after registration
NO_REPLY_ADDRESS = noreply@example.com
; Rate limiting for login attempts
MAX_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION = 30m
[openid]
; Disable OpenID login unless intentionally used
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[oauth2]
; Restrict OAuth2 app creation to admins only
; (requires Gitea 1.20+)
ENABLED = true
; All registered OAuth2 providers must be approved by admin
Step 3 — Harden webhook configuration to prevent SSRF
# /etc/gitea/app.ini
[webhook]
; Allowlist of IP/CIDR ranges that webhooks may target
; Blocks requests to internal network ranges including cloud metadata
ALLOWED_HOST_LIST = external,loopback
; "external" = deny RFC1918 private IPs and link-local
; "loopback" = allow 127.0.0.1 (for local integrations)
; Or specify explicit CIDR allowlist:
; ALLOWED_HOST_LIST = 10.20.0.0/24 (only your webhook consumer)
; Prevent webhooks to cloud metadata service
; 169.254.169.254 is blocked by ALLOWED_HOST_LIST = external
; Timeout for webhook delivery
DELIVER_TIMEOUT = 5
; Require HTTPS for webhooks in production
; (enforced at application level in newer versions)
Verify SSRF protection:
# Test: attempt to create a webhook targeting the metadata service
# This should be rejected at creation or delivery time
curl -X POST "https://gitea.example.com/api/v1/repos/user/repo/hooks" \
-H "Authorization: token $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "gitea",
"config": {
"url": "http://169.254.169.254/latest/meta-data/",
"content_type": "json"
},
"events": ["push"],
"active": true
}'
# Expected: 422 Unprocessable Entity or 400 Bad Request
# "url host is not allowed"
Step 4 — Harden the Gitea Actions runner configuration
# /etc/gitea-runner/config.yaml (Gitea Actions runner)
log:
level: info
runner:
# Rotate registration tokens regularly; never use the same token across runners
# Token is set via environment variable, not config file
# Run each job in an isolated Docker container (not directly on host)
capacity: 5
# Limit job execution environment
envs:
# Prevent job from reading host environment variables
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
cache:
# Disable remote cache if not needed (prevents data exfiltration via cache artifacts)
enabled: false
container:
# Use minimal base image; never use privileged containers
network: "bridge"
# Explicitly set to false — never run runner containers as privileged
privileged: false
# Mount only necessary volumes
options: "--cap-drop=ALL --security-opt=no-new-privileges"
# Gitea runner image — use pinned digest in production
valid_volumes:
- "/workspace/**"
- "/tmp/**"
Register runners with limited-scope tokens:
# Generate a new runner registration token (requires admin)
curl -X POST "https://gitea.example.com/api/v1/repos/org/repo/actions/runners/registration-token" \
-H "Authorization: token $ADMIN_TOKEN" | jq -r '.token'
# Register runner using the token
gitea-act-runner register \
--instance https://gitea.example.com \
--token "$RUNNER_TOKEN" \
--name "secure-runner-01" \
--labels "ubuntu-22.04:docker://ubuntu:22.04"
# Verify runner is registered and active
curl "https://gitea.example.com/api/v1/repos/org/repo/actions/runners" \
-H "Authorization: token $ADMIN_TOKEN" | jq '.runners[] | {name, status}'
Step 5 — Secure the app.ini configuration
# /etc/gitea/app.ini — full hardening configuration
[server]
PROTOCOL = https
DOMAIN = gitea.example.com
ROOT_URL = https://gitea.example.com/
; Bind to localhost if behind a reverse proxy (nginx/Traefik handles TLS)
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
; Disable admin panel access from external network (proxy handles this)
LOCAL_ROOT_URL = http://127.0.0.1:3000/
[database]
; Use separate DB user with minimal permissions (not the superuser)
DB_TYPE = postgres
HOST = db:5432
NAME = gitea
USER = gitea_app
PASSWD = ${DB_PASSWORD}
SSL_MODE = require
[security]
; Rotate this secret — never use the default
SECRET_KEY = ${SECRET_KEY}
INTERNAL_TOKEN = ${INTERNAL_TOKEN}
; Require HTTPS cookies
COOKIE_SECURE = true
; Prevent session fixation
CSRF_COOKIE_HTTP_ONLY = true
; Disable installation page (prevents re-initialization)
INSTALL_LOCK = true
; Restrict password strength
MIN_PASSWORD_LENGTH = 16
PASSWORD_COMPLEXITY = lower,upper,digit,spec
[admin]
; Disable creating admin account via env var in production
DISABLE_REGULAR_ORG_CREATION = false
[git]
; Limit repository size
MAX_GIT_DIFF_FILES = 100
MAX_GIT_DIFF_LINES = 1000
; Timeout for git operations (prevent resource exhaustion)
GC_ARGS = --aggressive
[api]
; Rate limit API requests
MAX_RESPONSE_ITEMS = 50
DEFAULT_PAGING_NUM = 20
; Require pagination for large result sets
ENABLE_SWAGGER = false ; Disable in production if not needed
[picture]
; Disable gravatar (external requests leak user emails)
DISABLE_GRAVATAR = true
ENABLE_FEDERATED_AVATAR = false
[mailer]
; Configure SPF/DKIM-aligned sender domain
FROM = gitea@example.com
Protect the config file:
# Ensure app.ini is readable only by the gitea user
chown gitea:gitea /etc/gitea/app.ini
chmod 0600 /etc/gitea/app.ini
# Verify
ls -la /etc/gitea/app.ini
# -rw------- 1 gitea gitea ... /etc/gitea/app.ini
Step 6 — Deploy a reverse proxy with security headers
Never expose Gitea directly; put it behind nginx or Traefik with security headers:
# /etc/nginx/sites-available/gitea
server {
listen 443 ssl http2;
server_name gitea.example.com;
ssl_certificate /etc/letsencrypt/live/gitea.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting for authentication endpoints
limit_req_zone $binary_remote_addr zone=gitea_auth:10m rate=10r/m;
location /user/login {
limit_req zone=gitea_auth burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /user/sign_up {
# Block registration endpoint if self-registration is disabled
return 404;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Step 7 — Monitor for compromise indicators
# Monitor Gitea audit logs (written to gitea.log when audit is enabled)
# Enable audit logging in app.ini:
# [log]
# MODE = file
# LEVEL = info
# [log.file]
# FILE_NAME = /var/log/gitea/gitea.log
# Key events to alert on:
# - New admin user created
# - SSH key added to admin account
# - OAuth2 app created
# - Webhook created with non-HTTPS URL
# - Repository import from external URL
# - Runner registration token accessed
# Using Falco for container-based deployments
# Falco rule for Gitea
- rule: Gitea Webhook to Internal Network
desc: Gitea process made outbound connection to RFC1918 address (SSRF indicator)
condition: >
outbound and
proc.name = gitea and
fd.rip | startswith("10.") or
fd.rip | startswith("172.16.") or
fd.rip | startswith("192.168.") or
fd.rip = "169.254.169.254"
output: >
Gitea SSRF attempt to internal address
(proc=%proc.name pid=%proc.pid rip=%fd.rip rport=%fd.rport)
priority: CRITICAL
Expected Behaviour
| Signal | Before hardening | After hardening |
|---|---|---|
| Anonymous user can register | Yes (default) | No — DISABLE_REGISTRATION = true |
Webhook to 169.254.169.254 |
Accepted; HTTP request made | Rejected at creation — ALLOWED_HOST_LIST = external |
app.ini permissions |
World-readable (0644) | Owner-only (0600) |
| Gitea served directly on port 3000 | Yes, no TLS | No — nginx proxy handles TLS; Gitea binds only to 127.0.0.1 |
| Runner containers run as privileged | Default Docker, potentially privileged | Explicitly privileged: false, --cap-drop=ALL |
INSTALL_LOCK |
false (risk of re-initialization) |
true |
Verification:
# Confirm registration is disabled
curl -s https://gitea.example.com/user/sign_up | grep -c "registration"
# Expected: 0 (or 404)
# Confirm webhook SSRF protection
curl -X POST "https://gitea.example.com/api/v1/repos/user/repo/hooks" \
-H "Authorization: token $TOKEN" \
-d '{"type":"gitea","config":{"url":"http://10.0.0.1/","content_type":"json"},"events":["push"],"active":true}' \
-H "Content-Type: application/json"
# Expected: 422 or 400 — URL not allowed
# Confirm app.ini is protected
stat -c "%a %U %G" /etc/gitea/app.ini
# Expected: 600 gitea gitea
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
DISABLE_REGISTRATION = true |
Prevents unauthorized account creation | Removes self-service onboarding; admin must provision each user | Implement an SSO integration (OIDC/SAML) so users provision themselves via corporate IdP; admin still controls who has IdP access |
ALLOWED_HOST_LIST = external |
Blocks SSRF to internal services | Breaks webhooks to internal CI systems (Jenkins, ArgoCD) | Add specific internal webhook consumer IPs to the allowlist explicitly; audit each addition |
Runner privileged: false |
Prevents container escapes from CI jobs | Breaks jobs that build Docker images (DinD) | Use rootless Buildkit or Kaniko for container builds inside CI; document the migration path |
DISABLE_GRAVATAR = true |
Prevents email hash leakage to Gravatar CDN | Users lose avatar images unless an alternative is configured | Use Gitea’s built-in avatar system or a self-hosted Libravatar instance |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
ALLOWED_HOST_LIST too restrictive breaks internal webhooks |
Webhook delivery fails; CI pipelines not triggered | Webhook delivery log in Gitea admin shows “host not allowed” | Add specific internal webhook receiver IPs to allowlist; test after each addition |
INSTALL_LOCK = true on unconfigured instance |
New deployment cannot complete setup wizard | Gitea startup log shows “already installed”; UI shows error | Remove INSTALL_LOCK, complete setup, then re-enable |
| Runner registration token rotated without updating runners | Runners fail to re-register after restart; jobs queue but never run | Runner list in Gitea admin shows all runners offline | Generate new token, update all runner configs, restart runner services; automate via secrets manager |
| TLS cert renewal breaks nginx proxy | Gitea unreachable via HTTPS after cert expiry | Certificate expiry monitoring alert; users report browser SSL error | Set up cert-manager or certbot renewal; test renewal procedure before expiry |
Related Articles
- GitHub Actions Supply Chain Hardening — runner isolation and token security patterns that apply equally to Gitea Actions runners
- Webhook Security Hardening — HMAC signature verification and URL validation for webhook consumers
- Secret Scanning in CI/CD — preventing secrets from entering the repository that Gitea hosts
- Self-Hosted Runner Security — hardening patterns for CI runners applicable to Gitea Actions
- OIDC Federation Hardening — using OIDC for Gitea authentication to replace password-based login