Skip to content

Commit 09dfd2e

Browse files
dvcdsysclaude
andcommitted
fix(server): address PR #45 review — auth, secret handling, installer, races
- High: require admin on TestTunnel/RestartTunnel/ReconcileWebhooks — these decrypt PATs, register GitHub webhooks, restart the subprocess, or probe outbound, and were only behind requireAuth (any authenticated user). Hide the dashboard "Re-register webhooks" button from non-admins. - Medium: pass the Cloudflare/ngrok tokens via env (TUNNEL_TOKEN / NGROK_AUTHTOKEN) instead of argv so they don't show in ps/proc cmdline. - Medium: installer no longer tracks "latest" — pin cloudflared version (synced with the Dockerfile arg); compute the download SHA-256, verify it against a pinned map when present and warn (with the sum) when not; cap the download/extraction size to guard against a decompression bomb. - Low: serialize Reconcile (mutex) so the boot double-reconcile can't create duplicate GitHub hooks; serialize Manager.Apply so concurrent applies can't orphan a provider; RestartTunnel uses a background-derived ctx so a client disconnect doesn't abort the spawn (matching UpdateTunnelConfig); capture readySignal under the lock before close to avoid a restart-time race. - Nits: drop stale CIX_TUNNEL_ENABLED wording from handler messages/comments. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2300997 commit 09dfd2e

7 files changed

Lines changed: 134 additions & 24 deletions

File tree

server/dashboard/src/modules/github-integration/WebhooksTab.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
22
import { Link } from 'react-router-dom';
33
import { AlertCircle, RefreshCw } from 'lucide-react';
44
import { api } from '@/api/client';
5+
import { useAuth } from '@/auth/useAuth';
56
import { Alert, AlertDescription, AlertTitle } from '@/ui/alert';
67
import { Button } from '@/ui/button';
78
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/card';
@@ -35,6 +36,8 @@ const ACTION_COLOR: Record<ReconcileOutcome['action'], string> = {
3536
// that provides the public URL is managed under Managed Tunnels — this tab
3637
// only consumes it.
3738
export default function WebhooksTab() {
39+
const { user } = useAuth();
40+
const isAdmin = user?.role === 'admin';
3841
const [tunnel, setTunnel] = useState<TunnelStatus | null>(null);
3942
const [result, setResult] = useState<ReconcileResult | null>(null);
4043
const [busy, setBusy] = useState(false);
@@ -98,12 +101,15 @@ export default function WebhooksTab() {
98101
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
99102
<p className="text-xs text-muted-foreground">
100103
Re-registration runs automatically on boot and when the tunnel URL
101-
changes. Use this to trigger it manually.
104+
changes.{' '}
105+
{isAdmin ? 'Use this to trigger it manually.' : 'Manual re-registration requires an admin.'}
102106
</p>
103-
<Button variant="outline" size="sm" disabled={busy} onClick={reconcile}>
104-
<RefreshCw className="mr-1 size-4" />
105-
{busy ? 'Re-registering…' : 'Re-register webhooks'}
106-
</Button>
107+
{isAdmin && (
108+
<Button variant="outline" size="sm" disabled={busy} onClick={reconcile}>
109+
<RefreshCw className="mr-1 size-4" />
110+
{busy ? 'Re-registering…' : 'Re-register webhooks'}
111+
</Button>
112+
)}
107113
</div>
108114

109115
{err && (

server/internal/httpapi/tunnels.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import (
1212
"github.com/dvcdsys/code-index/server/internal/tunnels"
1313
)
1414

15-
// disabledTunnelStatus is the snapshot returned when no tunnel is wired
16-
// (CIX_TUNNEL_ENABLED=false). The cloudflare slot is "available but off";
17-
// ngrok is reported as not-yet-implemented.
15+
// disabledTunnelStatus is the snapshot returned when the tunnel manager is
16+
// not wired (no provider running). Both providers are available to enable
17+
// from the dashboard.
1818
func disabledTunnelStatus() tunnels.Status {
1919
return tunnels.Status{Provider: tunnels.ProviderCloudflare, State: tunnels.StateDisabled, Available: true}
2020
}
@@ -177,10 +177,14 @@ func (s *Server) GetTunnelStatus(w http.ResponseWriter, _ *http.Request) {
177177
writeJSON(w, http.StatusOK, st)
178178
}
179179

180-
// TestTunnel — POST /api/v1/tunnels/test. End-to-end round-trip probe.
180+
// TestTunnel — POST /api/v1/tunnels/test. Admin-only. End-to-end round-trip
181+
// probe (initiates an outbound request through the tunnel).
181182
func (s *Server) TestTunnel(w http.ResponseWriter, r *http.Request) {
183+
if _, ok := s.mustBeAdmin(w, r); !ok {
184+
return
185+
}
182186
if s.Deps.Tunnel == nil {
183-
writeError(w, http.StatusConflict, "no active tunnel (CIX_TUNNEL_ENABLED=false)")
187+
writeError(w, http.StatusConflict, "no active tunnel (enable it under Managed Tunnels)")
184188
return
185189
}
186190
res, err := s.Deps.Tunnel.TestConnection(r.Context())
@@ -195,13 +199,22 @@ func (s *Server) TestTunnel(w http.ResponseWriter, r *http.Request) {
195199
writeJSON(w, http.StatusOK, res)
196200
}
197201

198-
// RestartTunnel — POST /api/v1/tunnels/restart. Restart the subprocess.
202+
// RestartTunnel — POST /api/v1/tunnels/restart. Admin-only. Restarts the
203+
// subprocess (a soft DoS in the wrong hands, hence admin).
199204
func (s *Server) RestartTunnel(w http.ResponseWriter, r *http.Request) {
205+
if _, ok := s.mustBeAdmin(w, r); !ok {
206+
return
207+
}
200208
if s.Deps.Tunnel == nil {
201-
writeError(w, http.StatusConflict, "no active tunnel (CIX_TUNNEL_ENABLED=false)")
209+
writeError(w, http.StatusConflict, "no active tunnel (enable it under Managed Tunnels)")
202210
return
203211
}
204-
if err := s.Deps.Tunnel.Restart(r.Context()); err != nil {
212+
// Restart spawns a subprocess and waits for readiness; drive it on a
213+
// background-derived deadline so a client disconnect doesn't abort the
214+
// spawn mid-flight (matches UpdateTunnelConfig).
215+
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
216+
defer cancel()
217+
if err := s.Deps.Tunnel.Restart(ctx); err != nil {
205218
if errors.Is(err, tunnels.ErrNoActiveTunnel) {
206219
writeError(w, http.StatusConflict, "no active tunnel")
207220
return
@@ -212,9 +225,13 @@ func (s *Server) RestartTunnel(w http.ResponseWriter, r *http.Request) {
212225
writeJSON(w, http.StatusOK, s.Deps.Tunnel.Status())
213226
}
214227

215-
// ReconcileWebhooks — POST /api/v1/github/webhooks/reconcile. Re-register
216-
// every webhook_mode=auto repo against the current public base URL.
228+
// ReconcileWebhooks — POST /api/v1/github/webhooks/reconcile. Admin-only —
229+
// it decrypts stored PATs and registers webhooks on GitHub across every
230+
// webhook_mode=auto repo.
217231
func (s *Server) ReconcileWebhooks(w http.ResponseWriter, r *http.Request) {
232+
if _, ok := s.mustBeAdmin(w, r); !ok {
233+
return
234+
}
218235
if s.Deps.WebhookReconciler == nil || s.Deps.GitRepos == nil {
219236
writeError(w, http.StatusServiceUnavailable, "GitHub repo support is not configured on this server")
220237
return

server/internal/tunnels/cloudflare.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88
"net/http"
9+
"os"
910
"os/exec"
1011
"regexp"
1112
"strings"
@@ -121,7 +122,9 @@ func (p *cloudflareProvider) argv() []string {
121122
case CloudflareModeQuick:
122123
args = append(args, "--url", fmt.Sprintf("http://localhost:%d", p.localPort))
123124
case CloudflareModeNamed:
124-
args = append(args, "run", "--token", p.token)
125+
// Token is passed via the TUNNEL_TOKEN env var (see spawn), not argv,
126+
// so it doesn't appear in ps / /proc/<pid>/cmdline.
127+
args = append(args, "run")
125128
}
126129
return args
127130
}
@@ -136,6 +139,11 @@ func (p *cloudflareProvider) spawn(ctx context.Context) error {
136139
)
137140

138141
cmd := exec.Command(p.binPath, argv...)
142+
// Pass the named-tunnel token via env (TUNNEL_TOKEN) rather than argv so
143+
// it never appears in the process table.
144+
if p.token != "" {
145+
cmd.Env = append(os.Environ(), "TUNNEL_TOKEN="+p.token)
146+
}
139147
// cloudflared writes its logs (including the quick-tunnel URL) to stderr.
140148
stdoutLog := newLineLogWriter(p.logger, "cloudflared.stdout", p.scanLine)
141149
stderrLog := newLineLogWriter(p.logger, "cloudflared.stderr", p.scanLine)
@@ -151,7 +159,8 @@ func (p *cloudflareProvider) spawn(ctx context.Context) error {
151159
p.mu.Lock()
152160
p.cmd = cmd
153161
p.startedAt = time.Now()
154-
p.readySignal = make(chan struct{})
162+
readyCh := make(chan struct{})
163+
p.readySignal = readyCh
155164
p.waiterDone = make(chan struct{})
156165
// A fresh quick tunnel gets a new URL; clear the stale one so waitReady
157166
// blocks until the new URL is parsed.
@@ -171,7 +180,7 @@ func (p *cloudflareProvider) spawn(ctx context.Context) error {
171180
<-p.waiterDone
172181
return fmt.Errorf("cloudflared not ready: %w", err)
173182
}
174-
close(p.readySignal)
183+
close(readyCh)
175184
p.lastSpawnErr.Store("")
176185
p.logger.Info("cloudflared ready", "public_url", p.URL(), "elapsed", time.Since(p.startedAt).String())
177186
return nil

server/internal/tunnels/installer.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"archive/tar"
55
"compress/gzip"
66
"context"
7+
"crypto/sha256"
8+
"encoding/hex"
79
"fmt"
810
"io"
911
"log/slog"
@@ -16,6 +18,24 @@ import (
1618
"time"
1719
)
1820

21+
// cloudflaredVersion pins the cloudflared release the managed installer
22+
// downloads. Pinned (not "latest") for reproducibility — keep it in sync
23+
// with the CLOUDFLARED_VERSION arg in server/Dockerfile*. ngrok is fetched
24+
// from its "v3-stable" channel (ngrok publishes no per-version URLs).
25+
const cloudflaredVersion = "2025.2.1"
26+
27+
// maxBinaryBytes caps a managed download to guard against a decompression
28+
// bomb / runaway response. Comfortably above the real agents (~35 MB).
29+
const maxBinaryBytes = 300 << 20 // 300 MiB
30+
31+
// pinnedSHA256 maps "<provider>/<version>/<goos>/<goarch>" → expected
32+
// hex SHA-256 of the downloaded artifact (raw binary for cloudflared/linux,
33+
// the .tgz for archived assets). When an entry exists the download is
34+
// verified against it; when absent the installer logs a warning with the
35+
// computed sum so an operator can pin it. Populate from the vendor's
36+
// published checksums.
37+
var pinnedSHA256 = map[string]string{}
38+
1939
// BinaryStatus describes a provider's agent binary for the dashboard, so it
2040
// can render install instructions (local) or Install/Update buttons
2141
// (managed).
@@ -108,17 +128,37 @@ func (in *Installer) Install(ctx context.Context, provider string) error {
108128
}
109129
cleanup := func() { _ = out.Close(); _ = os.Remove(partial) }
110130

131+
// Hash the downloaded artifact (the .tgz for archived assets, the raw
132+
// binary otherwise) while writing, bounded by maxBinaryBytes.
133+
hasher := sha256.New()
134+
src := io.TeeReader(io.LimitReader(resp.Body, maxBinaryBytes), hasher)
111135
if archived {
112-
if err := extractFromTarGz(resp.Body, member, out); err != nil {
136+
if err := extractFromTarGz(src, member, out); err != nil {
113137
cleanup()
114138
return err
115139
}
140+
// Drain the rest of the archive so the hash covers the whole .tgz.
141+
_, _ = io.Copy(io.Discard, src)
116142
} else {
117-
if _, err := io.Copy(out, resp.Body); err != nil {
143+
if _, err := io.Copy(out, src); err != nil {
118144
cleanup()
119145
return fmt.Errorf("write binary: %w", err)
120146
}
121147
}
148+
149+
sum := hex.EncodeToString(hasher.Sum(nil))
150+
key := provider + "/" + assetVersion(provider) + "/" + runtime.GOOS + "/" + runtime.GOARCH
151+
if want, ok := pinnedSHA256[key]; ok {
152+
if !strings.EqualFold(want, sum) {
153+
cleanup()
154+
return fmt.Errorf("checksum mismatch for %s: got %s, want %s", key, sum, want)
155+
}
156+
in.logger.Info("tunnel binary checksum verified", "provider", provider, "sha256", sum)
157+
} else {
158+
in.logger.Warn("tunnel binary downloaded without checksum verification (HTTPS from official source)",
159+
"provider", provider, "url", url, "sha256", sum)
160+
}
161+
122162
if err := out.Sync(); err != nil {
123163
cleanup()
124164
return fmt.Errorf("fsync binary: %w", err)
@@ -168,7 +208,8 @@ func extractFromTarGz(r io.Reader, member string, out io.Writer) error {
168208
func assetURL(provider, goos, goarch string) (url string, archived bool, member string, err error) {
169209
switch provider {
170210
case ProviderCloudflare:
171-
const base = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
211+
// Pinned version (not "latest") for reproducibility.
212+
base := "https://github.com/cloudflare/cloudflared/releases/download/" + cloudflaredVersion + "/"
172213
switch goos {
173214
case "linux":
174215
return base + "cloudflared-linux-" + goarch, false, "", nil
@@ -185,6 +226,15 @@ func assetURL(provider, goos, goarch string) (url string, archived bool, member
185226
}
186227
}
187228

229+
// assetVersion is the version identifier used in the pinnedSHA256 key for a
230+
// provider (cloudflared is pinned; ngrok tracks its stable channel).
231+
func assetVersion(provider string) string {
232+
if provider == ProviderNgrok {
233+
return "v3-stable"
234+
}
235+
return cloudflaredVersion
236+
}
237+
188238
// binaryVersion runs the agent's version subcommand best-effort. Returns ""
189239
// on any failure — it's informational only.
190240
func binaryVersion(ctx context.Context, provider, path string) string {

server/internal/tunnels/manager.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ type Manager struct {
6060

6161
installer *Installer // nil unless BinManaged
6262

63+
// applyMu serializes the whole Apply operation so two concurrent applies
64+
// can't interleave their stop-old / start-new steps and leak an orphan
65+
// provider. mu (below) only guards individual field reads/writes.
66+
applyMu sync.Mutex
67+
6368
mu sync.RWMutex
6469
active Provider
6570
onURLChange func(string)
@@ -144,6 +149,11 @@ func (m *Manager) InstallBinary(ctx context.Context, provider string) error {
144149
// can see the error and retry — Apply returns the error for the caller to
145150
// relay, it does not panic the server.
146151
func (m *Manager) Apply(ctx context.Context, set Settings) error {
152+
// Serialize the entire apply so concurrent calls can't interleave the
153+
// stop-old / start-new steps (which would orphan a running provider).
154+
m.applyMu.Lock()
155+
defer m.applyMu.Unlock()
156+
147157
m.mu.Lock()
148158
m.lastSettings = set
149159
old := m.active

server/internal/tunnels/ngrok.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"net/http"
8+
"os"
89
"os/exec"
910
"regexp"
1011
"strconv"
@@ -104,8 +105,9 @@ func (p *ngrokProvider) Name() string { return ProviderNgrok }
104105
func (p *ngrokProvider) Start(ctx context.Context) error { return p.spawn(ctx) }
105106

106107
func (p *ngrokProvider) argv() []string {
108+
// The authtoken is passed via NGROK_AUTHTOKEN env (see spawn), not argv,
109+
// so it doesn't appear in ps / /proc/<pid>/cmdline.
107110
args := []string{"http", strconv.Itoa(p.localPort),
108-
"--authtoken", p.token,
109111
"--log", "stdout", "--log-format", "json"}
110112
if p.cfg.Mode == ngrokModeNamed {
111113
// --url takes a full origin (ngrok v3.5+); older agents used
@@ -120,6 +122,9 @@ func (p *ngrokProvider) spawn(ctx context.Context) error {
120122
p.logger.Info("spawning ngrok", "bin", p.binPath, "mode", p.cfg.Mode, "local_port", p.localPort)
121123

122124
cmd := exec.Command(p.binPath, p.argv()...)
125+
// Pass the authtoken via env (NGROK_AUTHTOKEN) rather than argv so it
126+
// never appears in the process table.
127+
cmd.Env = append(os.Environ(), "NGROK_AUTHTOKEN="+p.token)
123128
stdoutLog := newLineLogWriter(p.logger, "ngrok.stdout", p.scanLine)
124129
stderrLog := newLineLogWriter(p.logger, "ngrok.stderr", p.scanLine)
125130
cmd.Stdout = stdoutLog
@@ -133,7 +138,8 @@ func (p *ngrokProvider) spawn(ctx context.Context) error {
133138
p.mu.Lock()
134139
p.cmd = cmd
135140
p.startedAt = time.Now()
136-
p.readySignal = make(chan struct{})
141+
readyCh := make(chan struct{})
142+
p.readySignal = readyCh
137143
p.waiterDone = make(chan struct{})
138144
p.publicURL = ""
139145
p.mu.Unlock()
@@ -150,7 +156,7 @@ func (p *ngrokProvider) spawn(ctx context.Context) error {
150156
<-p.waiterDone
151157
return fmt.Errorf("ngrok not ready: %w", err)
152158
}
153-
close(p.readySignal)
159+
close(readyCh)
154160
p.lastSpawnErr.Store("")
155161
p.logger.Info("ngrok ready", "public_url", p.URL(), "elapsed", time.Since(p.startedAt).String())
156162
return nil

server/internal/tunnels/reconciler.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"log/slog"
77
"strings"
8+
"sync"
89

910
"github.com/dvcdsys/code-index/server/internal/githubapi"
1011
"github.com/dvcdsys/code-index/server/internal/gitrepos"
@@ -45,6 +46,14 @@ type Reconciler struct {
4546
tokens TokenRevealer
4647
gh WebhookClient
4748
logger *slog.Logger
49+
50+
// mu serializes Reconcile. On boot two reconciles can race (the
51+
// OnURLChange callback for a freshly-parsed quick-tunnel URL + the
52+
// explicit boot reconcile); without serialization both would take the
53+
// create branch for a repo that has no WebhookID yet and register
54+
// duplicate hooks on GitHub. Serializing means the second run re-reads
55+
// the now-persisted WebhookID and PATCHes instead.
56+
mu sync.Mutex
4857
}
4958

5059
func NewReconciler(repos RepoStore, tokens TokenRevealer, gh WebhookClient, logger *slog.Logger) *Reconciler {
@@ -77,6 +86,9 @@ type ReconcileResult struct {
7786
// has the hook); one without is created. baseURL must be an absolute http(s)
7887
// origin — when empty (no live tunnel) reconcile is a no-op.
7988
func (r *Reconciler) Reconcile(ctx context.Context, baseURL string) (ReconcileResult, error) {
89+
r.mu.Lock()
90+
defer r.mu.Unlock()
91+
8092
res := ReconcileResult{BaseURL: baseURL}
8193
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
8294
if !strings.HasPrefix(baseURL, "http") {

0 commit comments

Comments
 (0)