Skip to content

Commit 96ca37a

Browse files
jongioCopilot
andauthored
fix: MQ Wave 1 security hardening and code review fixes (#288)
Security hardening: - Add RPC session token authentication (X-Session-Token header) - Restrict healthcheck URLs to localhost only (SSRF prevention) - Add log secret masking for file output (key=value, JWT patterns) - Dashboard fetches and injects session token via Connect interceptor Code review fixes: - Fix operator precedence in isFileLockingError (gate on Windows first) - Replace time.Sleep with context-aware select in retry loop - Fix stderr double-wrapping on installer retry - Propagate context through hook execution chain - Add nil-check for LogsStoreFuncs in NewLogsHandler - Extract updateRegistryWithRetry helper (dedup ~40 lines) - Sanitize serviceName with filepath.Base in logbuffer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cb5ea10 commit 96ca37a

14 files changed

Lines changed: 260 additions & 74 deletions

File tree

cli/dashboard/src/lib/connectClient.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* below. Do NOT cache the client at module scope — the caller (typically
2121
* a hook with a stable transport reference) is responsible for memoising.
2222
*/
23-
import { createClient, type Client, type Transport } from '@connectrpc/connect'
23+
import { createClient, type Client, type Interceptor, type Transport } from '@connectrpc/connect'
2424
import { createConnectTransport } from '@connectrpc/connect-web'
2525

2626
import { LifecycleService } from '@/gen/proto/azdapp/v1/lifecycle_pb.js'
@@ -55,6 +55,40 @@ function defaultBaseUrl(): string {
5555
}
5656

5757
let cachedDefaultTransport: Transport | null = null
58+
let cachedSessionToken: string | null = null
59+
60+
/**
61+
* Fetch the session token from the server. Called once and cached.
62+
* The token is used to authenticate all RPC requests.
63+
*/
64+
async function getSessionToken(): Promise<string> {
65+
if (cachedSessionToken !== null) {
66+
return cachedSessionToken
67+
}
68+
try {
69+
const resp = await fetch('/api/session-token')
70+
if (resp.ok) {
71+
cachedSessionToken = await resp.text()
72+
}
73+
} catch {
74+
// Server may not support session tokens (e.g., test environments)
75+
}
76+
return cachedSessionToken ?? ''
77+
}
78+
79+
/**
80+
* Connect interceptor that attaches the session token to every outbound
81+
* request. The token is fetched lazily on first use.
82+
*/
83+
function sessionTokenInterceptor(): Interceptor {
84+
return (next) => async (req) => {
85+
const token = await getSessionToken()
86+
if (token) {
87+
req.header.set('X-Session-Token', token)
88+
}
89+
return next(req)
90+
}
91+
}
5892

5993
/**
6094
* Returns the singleton dashboard transport. Construction is deferred to
@@ -74,6 +108,7 @@ export function getDefaultTransport(): Transport {
74108
// handlers (no protobuf-binary opt-in yet) and keeps wire payloads
75109
// inspectable in DevTools during the migration.
76110
useBinaryFormat: false,
111+
interceptors: [sessionTokenInterceptor()],
77112
})
78113
}
79114
return cachedDefaultTransport

cli/src/cmd/app/commands/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func runAzdMode(ctx context.Context, azureYamlPath, azureYamlDir string) error {
146146
// Azure logs are now fetched on-demand via /api/azure/logs endpoint
147147

148148
// Execute prerun hook before starting services
149-
if err = executePrerunHook(azureYaml, azureYamlDir); err != nil {
149+
if err = executePrerunHook(ctx, azureYaml, azureYamlDir); err != nil {
150150
return err
151151
}
152152

cli/src/cmd/app/commands/run_hooks.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ import (
1212
)
1313

1414
// executePrerunHook executes the prerun hook if configured.
15-
func executePrerunHook(azureYaml *service.AzureYaml, workingDir string) error {
16-
return executeHook(azureYaml, azureYaml.Hooks, azureYaml.Hooks.GetPrerun(), "prerun", workingDir)
15+
func executePrerunHook(ctx context.Context, azureYaml *service.AzureYaml, workingDir string) error {
16+
return executeHook(ctx, azureYaml, azureYaml.Hooks, azureYaml.Hooks.GetPrerun(), "prerun", workingDir)
1717
}
1818

1919
// executePostrunHook executes the postrun hook if configured.
20-
func executePostrunHook(azureYaml *service.AzureYaml, workingDir string) error {
21-
return executeHook(azureYaml, azureYaml.Hooks, azureYaml.Hooks.GetPostrun(), "postrun", workingDir)
20+
func executePostrunHook(ctx context.Context, azureYaml *service.AzureYaml, workingDir string) error {
21+
return executeHook(ctx, azureYaml, azureYaml.Hooks, azureYaml.Hooks.GetPostrun(), "postrun", workingDir)
2222
}
2323

2424
// executeHook executes a lifecycle hook with the given name and configuration.
2525
// This is a common helper function to avoid duplication between prerun and postrun hooks.
26-
func executeHook(azureYaml *service.AzureYaml, hooks *service.Hooks, hook *service.Hook, hookName, workingDir string) error {
26+
func executeHook(ctx context.Context, azureYaml *service.AzureYaml, hooks *service.Hooks, hook *service.Hook, hookName, workingDir string) error {
2727
if hooks == nil || hook == nil {
2828
return nil // No hook configured
2929
}
@@ -40,7 +40,7 @@ func executeHook(azureYaml *service.AzureYaml, hooks *service.Hooks, hook *servi
4040
hookEnvVars := buildHookEnvironmentVariables(azureYaml, workingDir)
4141
config.Env = hookEnvVars
4242

43-
return executor.ExecuteHook(context.Background(), hookName, *config, workingDir)
43+
return executor.ExecuteHook(ctx, hookName, *config, workingDir)
4444
}
4545

4646
// buildHookEnvironmentVariables builds environment variables to pass to hooks

cli/src/cmd/app/commands/run_hooks_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ services:
4848
t.Fatalf("Failed to parse azure.yaml: %v", err)
4949
}
5050

51-
err = executePrerunHook(azureYaml, tmpDir)
51+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
5252
if err != nil {
5353
t.Errorf("Expected prerun hook to succeed, got error: %v", err)
5454
}
@@ -91,7 +91,7 @@ services:
9191
t.Fatalf("Failed to parse azure.yaml: %v", err)
9292
}
9393

94-
err = executePrerunHook(azureYaml, tmpDir)
94+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
9595
if err == nil {
9696
t.Error("Expected prerun hook to fail, but it succeeded")
9797
}
@@ -134,7 +134,7 @@ services:
134134
t.Fatalf("Failed to parse azure.yaml: %v", err)
135135
}
136136

137-
err = executePrerunHook(azureYaml, tmpDir)
137+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
138138
if err != nil {
139139
t.Errorf("Expected prerun hook to continue on error, got: %v", err)
140140
}
@@ -174,7 +174,7 @@ services:
174174
t.Fatalf("Failed to parse azure.yaml: %v", err)
175175
}
176176

177-
err = executePostrunHook(azureYaml, tmpDir)
177+
err = executePostrunHook(context.Background(), azureYaml, tmpDir)
178178
if err != nil {
179179
t.Errorf("Expected postrun hook to succeed, got error: %v", err)
180180
}
@@ -217,7 +217,7 @@ services:
217217
t.Fatalf("Failed to parse azure.yaml: %v", err)
218218
}
219219

220-
err = executePostrunHook(azureYaml, tmpDir)
220+
err = executePostrunHook(context.Background(), azureYaml, tmpDir)
221221
if err == nil {
222222
t.Error("Expected postrun hook to fail, but it succeeded")
223223
}
@@ -245,12 +245,12 @@ services:
245245
}
246246

247247
// Should not error when no hooks configured
248-
err = executePrerunHook(azureYaml, tmpDir)
248+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
249249
if err != nil {
250250
t.Errorf("Expected no error when hooks not configured, got: %v", err)
251251
}
252252

253-
err = executePostrunHook(azureYaml, tmpDir)
253+
err = executePostrunHook(context.Background(), azureYaml, tmpDir)
254254
if err != nil {
255255
t.Errorf("Expected no error when hooks not configured, got: %v", err)
256256
}
@@ -492,7 +492,7 @@ services:
492492
}
493493

494494
// Execute prerun hook - it should have access to environment variables
495-
err = executePrerunHook(azureYaml, tmpDir)
495+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
496496
if err != nil {
497497
t.Logf("Hook execution error (may be platform-specific): %v", err)
498498
// Don't fail the test - the environment variable logic may vary by platform
@@ -546,7 +546,7 @@ services:
546546

547547
done := make(chan error, 1)
548548
go func() {
549-
done <- executePrerunHook(azureYaml, tmpDir)
549+
done <- executePrerunHook(context.Background(), azureYaml, tmpDir)
550550
}()
551551

552552
select {
@@ -600,7 +600,7 @@ services:
600600
}
601601

602602
// Execute prerun hook - should use platform-specific command
603-
err = executePrerunHook(azureYaml, tmpDir)
603+
err = executePrerunHook(context.Background(), azureYaml, tmpDir)
604604
if err != nil {
605605
t.Errorf("Expected platform-specific hook to succeed, got error: %v", err)
606606
}

cli/src/cmd/app/commands/run_orchestration.go

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func executeAndMonitorServices(ctx context.Context, runtimes []*service.ServiceR
5050
logger.LogReady()
5151

5252
// Execute postrun hook after all services are ready
53-
if err := executePostrunHook(azureYaml, azureYamlDir); err != nil {
53+
if err := executePostrunHook(ctx, azureYaml, azureYamlDir); err != nil {
5454
cliout.Warning("Postrun hook failed but services are running: %v", err)
5555
}
5656

@@ -257,25 +257,8 @@ func monitorServiceProcess(ctx context.Context, wg *sync.WaitGroup, serviceName
257257

258258
if result.err != nil {
259259
// Update registry to trigger OS notification via state monitor
260-
// CRITICAL FIX: Implement retry logic for registry updates
261-
maxRetries := 3
262-
retryDelay := 100 * time.Millisecond
263-
var regErr error
264-
for i := 0; i < maxRetries; i++ {
265-
regErr = reg.UpdateStatus(serviceName, "error")
266-
if regErr == nil {
267-
break
268-
}
269-
if i < maxRetries-1 {
270-
time.Sleep(retryDelay)
271-
retryDelay *= 2 // Exponential backoff
272-
}
273-
}
274-
275-
if regErr != nil {
276-
cliout.Error("Failed to update registry for %s after %d retries: %v", serviceName, maxRetries, regErr)
277-
// As fallback, try to send direct notification if notification manager is available
278-
// This ensures users are informed even if registry update fails
260+
if regErr := updateRegistryWithRetry(reg, serviceName, "error"); regErr != nil {
261+
cliout.Error("Failed to update registry for %s after retries: %v", serviceName, regErr)
279262
}
280263

281264
// Show mode-appropriate error message
@@ -305,23 +288,8 @@ func monitorServiceProcess(ctx context.Context, wg *sync.WaitGroup, serviceName
305288
cliout.Info("Service %s exited cleanly", serviceName)
306289
}
307290

308-
// CRITICAL FIX: Implement retry logic for clean exit registry updates
309-
maxRetries := 3
310-
retryDelay := 100 * time.Millisecond
311-
var regErr error
312-
for i := 0; i < maxRetries; i++ {
313-
regErr = reg.UpdateStatus(serviceName, status)
314-
if regErr == nil {
315-
break
316-
}
317-
if i < maxRetries-1 {
318-
time.Sleep(retryDelay)
319-
retryDelay *= 2 // Exponential backoff
320-
}
321-
}
322-
323-
if regErr != nil {
324-
cliout.Warning("Failed to update registry for %s after %d retries: %v", serviceName, maxRetries, regErr)
291+
if regErr := updateRegistryWithRetry(reg, serviceName, status); regErr != nil {
292+
cliout.Warning("Failed to update registry for %s after retries: %v", serviceName, regErr)
325293
}
326294
}
327295
// Intentionally don't cancel context - other services should continue
@@ -402,3 +370,21 @@ func runAspireMode(ctx context.Context, rootDir string) error {
402370
// Run dotnet and let it handle everything (inherits all azd env vars)
403371
return executor.StartCommand(ctx, "dotnet", args, aspireProject.Dir)
404372
}
373+
374+
// updateRegistryWithRetry updates the service registry with retry and exponential backoff.
375+
func updateRegistryWithRetry(reg *registry.ServiceRegistry, serviceName, status string) error {
376+
const maxRetries = 3
377+
retryDelay := 100 * time.Millisecond
378+
var err error
379+
for i := 0; i < maxRetries; i++ {
380+
err = reg.UpdateStatus(serviceName, status)
381+
if err == nil {
382+
return nil
383+
}
384+
if i < maxRetries-1 {
385+
time.Sleep(retryDelay)
386+
retryDelay *= 2
387+
}
388+
}
389+
return err
390+
}

cli/src/internal/dashboard/server_core.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/jongio/azd-app/cli/src/internal/constants"
1515
"github.com/jongio/azd-app/cli/src/internal/dashboard/broadcast"
1616
"github.com/jongio/azd-app/cli/src/internal/portmanager"
17+
"github.com/jongio/azd-app/cli/src/internal/rpc"
1718
"github.com/jongio/azd-app/cli/src/internal/service"
1819
)
1920

@@ -36,6 +37,7 @@ type Server struct {
3637
currentMode service.LogMode // Current log source mode (local or azure)
3738
modeMu sync.RWMutex // Protect currentMode
3839
azureYamlMu sync.RWMutex // Protect azure.yaml read/write across Connect handlers
40+
sessionToken string // Per-session auth token for RPC endpoints
3941

4042
// broadcast fans coarse-grained UI events out to Connect
4143
// StreamBroadcast subscribers. See cli/src/internal/dashboard/broadcast
@@ -58,12 +60,13 @@ func GetServer(projectDir string) *Server {
5860

5961
// Create new server instance for this project
6062
srv := &Server{
61-
port: 0, // Will be assigned by port manager
62-
mux: http.NewServeMux(),
63-
projectDir: absPath,
64-
stopChan: make(chan struct{}),
65-
currentMode: service.LogModeLocal, // Default to local mode
66-
broadcast: broadcast.New(),
63+
port: 0, // Will be assigned by port manager
64+
mux: http.NewServeMux(),
65+
projectDir: absPath,
66+
stopChan: make(chan struct{}),
67+
currentMode: service.LogModeLocal, // Default to local mode
68+
broadcast: broadcast.New(),
69+
sessionToken: rpc.GenerateSessionToken(),
6770
}
6871
srv.setupRoutes()
6972
servers[key] = srv

cli/src/internal/dashboard/server_routes.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ func (s *Server) setupRoutes() {
3232
// All legacy /api/* REST handlers and /api/ws WebSocket have been
3333
// removed; dashboard clients use the azdapp.v1.* Connect services.
3434
rpc.Mount(s.mux, rpc.Dependencies{
35-
Broadcast: s.broadcast,
36-
Version: version.Version,
35+
Broadcast: s.broadcast,
36+
Version: version.Version,
37+
SessionToken: s.sessionToken,
3738
Project: rpc.ProjectSourceFunc(service.ParseAzureYaml),
3839
ProjectDir: s.projectDir,
3940
Mode: rpc.ModeStoreFuncs{
@@ -81,6 +82,14 @@ func (s *Server) setupRoutes() {
8182
Azure: newAzureStoreFuncs(s),
8283
})
8384

85+
// Serve session token for dashboard authentication.
86+
// The React app fetches this on startup and includes it in all RPC calls.
87+
s.mux.HandleFunc("/api/session-token", func(w http.ResponseWriter, r *http.Request) {
88+
w.Header().Set("Content-Type", "text/plain")
89+
w.Header().Set("Cache-Control", "no-store")
90+
_, _ = w.Write([]byte(s.sessionToken))
91+
})
92+
8493
// Pre-read index.html once for SPA client-side routing fallback
8594
indexContent, indexReadErr := fs.ReadFile(distFS, "index.html")
8695

cli/src/internal/healthcheck/checker_http.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,47 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"net"
89
"net/http"
10+
"net/url"
911
"strings"
1012
"time"
1113

1214
"log/slog"
1315
)
1416

17+
// isLocalhostURL checks if a URL targets localhost/loopback addresses only.
18+
// Health check URLs from azure.yaml are restricted to local services to prevent SSRF.
19+
func isLocalhostURL(urlStr string) error {
20+
parsed, err := url.Parse(urlStr)
21+
if err != nil {
22+
return fmt.Errorf("invalid URL: %w", err)
23+
}
24+
25+
host := parsed.Hostname()
26+
if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" {
27+
return nil
28+
}
29+
30+
// Check if it resolves to a loopback address
31+
ip := net.ParseIP(host)
32+
if ip != nil && ip.IsLoopback() {
33+
return nil
34+
}
35+
36+
return fmt.Errorf("health check URL must target localhost (got %q) - non-local URLs are blocked for security", host)
37+
}
38+
1539
func (c *HealthChecker) performHTTPCheck(ctx context.Context, urlStr string) *httpHealthCheckResult {
40+
// Restrict health check URLs to localhost to prevent SSRF
41+
if err := isLocalhostURL(urlStr); err != nil {
42+
return &httpHealthCheckResult{
43+
Endpoint: urlStr,
44+
Status: HealthStatusUnhealthy,
45+
Error: err.Error(),
46+
}
47+
}
48+
1649
startTime := time.Now()
1750
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
1851
if err != nil {

0 commit comments

Comments
 (0)