fix(ci-setup): harden generated deployment security defaults

- No-auth docker-compose now publishes via ${GITNEXUS_HOST:-127.0.0.1} so
  it binds loopback by default (not 0.0.0.0) and is not exposed by
  accident; operators set GITNEXUS_HOST to an interface IP / 0.0.0.0 for
  LAN access. The container healthcheck keeps localhost (it curls its own
  loopback, so it must not use GITNEXUS_HOST).
- Caddyfile gains TLS guidance (the proxy is plain HTTP, so the bearer
  token is cleartext — use a hostname for Caddy auto-HTTPS or terminate
  TLS upstream) and a standalone empty-token warning (compose already
  enforces a non-empty GITNEXUS_TOKEN via ${GITNEXUS_TOKEN:?...}).
- GITNEXUS.md (token mode) warns the bearer token travels in cleartext
  and to put the server behind TLS before exposing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 09:13:17 +00:00
parent b05e71c644
commit 1e950b1295
2 changed files with 49 additions and 3 deletions

View file

@ -186,12 +186,16 @@ ${GENERATED_NOTICE}
# ⚠ NO AUTH — trusted internal network only.
# Anyone who can reach port ${opts.port} has full access to all indexed repositories.
# Do not expose this port to the public internet.
#
# The publish address is controlled by GITNEXUS_HOST (default: 127.0.0.1, loopback
# only — so it is not exposed by accident). Set GITNEXUS_HOST to an interface IP
# (or 0.0.0.0) to allow LAN access, only on a trusted network.
services:
gitnexus:
image: ${GITNEXUS_IMAGE}
ports:
- "\${GITNEXUS_PORT:-${opts.port}}:${CONTAINER_PORT}"
- "\${GITNEXUS_HOST:-127.0.0.1}:\${GITNEXUS_PORT:-${opts.port}}:${CONTAINER_PORT}"
volumes:
- gitnexus-data:/data/gitnexus
- \${WORKSPACE_DIR:-./workspace}:/workspace:ro
@ -216,7 +220,19 @@ function buildDockerCompose(opts: CiSetupOptions): string {
function buildCaddyfile(opts: CiSetupOptions): string {
const proxyPort = caddyProxyPort(opts);
return `:${proxyPort} {
return `# Generated by: gitnexus ci-setup
#
# ⚠ This proxy listens on plain HTTP, so the GITNEXUS_TOKEN bearer token travels
# in cleartext. Do NOT expose it beyond a trusted host/LAN as-is. For an
# internet-reachable deployment, give the site a real hostname so Caddy
# auto-provisions HTTPS (replace ":${proxyPort}" with "your.host.example" and let
# Caddy serve :443), or terminate TLS at an upstream load balancer.
#
# GITNEXUS_TOKEN MUST be set to a non-empty value. The bundled docker-compose
# enforces this (\${GITNEXUS_TOKEN:?...}); if you run this Caddyfile standalone,
# set the env var first — otherwise the matcher below becomes "Bearer " and any
# request with an empty bearer token is accepted.
:${proxyPort} {
@authorized header Authorization "Bearer {env.GITNEXUS_TOKEN}"
handle @authorized {
@ -394,7 +410,11 @@ Add to \`~/.cursor/mcp.json\` under \`mcpServers\`:
}
\`\`\`
*Or* point Cursor at the shared HTTP server using the same \`type: http\` entry as Claude Code.`
*Or* point Cursor at the shared HTTP server using the same \`type: http\` entry as Claude Code.
> **Security:** the proxy serves plain HTTP, so the bearer token is sent in cleartext. Put it behind
> TLS — give Caddy a real hostname for automatic HTTPS, or terminate TLS at a load balancer — before
> exposing the server beyond localhost or a trusted LAN.`
: `### Claude Code
Copy the MCP entry from \`.claude/gitnexus-mcp-snippet.json\` into \`~/.claude/settings.json\`:

View file

@ -86,6 +86,32 @@ describe('GITNEXUS.md / workflow accuracy (U8)', () => {
});
});
describe('security hardening (U5)', () => {
it('no-auth compose binds via GITNEXUS_HOST defaulting to loopback', () => {
const files = generateFiles(makeOpts({ auth: 'none', port: 4747 }), DEFAULT_DETECT);
const dc = files.find((f) => f.relativePath === 'docker-compose.gitnexus.yml');
expect(dc?.content).toContain('${GITNEXUS_HOST:-127.0.0.1}:${GITNEXUS_PORT:-4747}:4747');
// still valid YAML with the host prefix
expect(() => yaml.load(dc?.content ?? '')).not.toThrow();
});
it('Caddyfile carries TLS guidance and a standalone empty-token warning', () => {
const files = generateFiles(makeOpts({ auth: 'token' }), DEFAULT_DETECT);
const cf = files.find((f) => f.relativePath === 'Caddyfile');
expect(cf?.content).toContain('HTTPS');
expect(cf?.content).toContain('cleartext');
expect(cf?.content).toContain('GITNEXUS_TOKEN MUST be set');
// the gate itself is unchanged (fail-closed for a non-empty token)
expect(cf?.content).toContain('respond "Unauthorized" 401');
});
it('GITNEXUS.md (token mode) warns the bearer token is cleartext over HTTP', () => {
const content = gitnexusMd({ auth: 'token' });
expect(content).toContain('cleartext');
expect(content).toContain('TLS');
});
});
describe('version pinning (U9)', () => {
it('pins the GitHub Actions workflow analyze step to the wizard version', () => {
const files = generateFiles(makeOpts({ version: '9.9.9' }), DEFAULT_DETECT);