CVE-2022-24829: Missing Authentication for Critical Function (CWE-306)
value: Garden is an automation platform for Kubernetes development and testing. In versions prior to 0.12.39 multiple endpoints did not require authentication. In some operating modes this allows for an attacker to gain access to the application erroneously. The configuration is leaked through the /api endpoint on the local server that is responsible for serving the Garden dashboard. At the moment, this server is accessible to 0.0.0.0 which makes it accessible to anyone on the same network (or anyone on the internet if they are on a public, static IP). This may lead to the ability to compromise credentials, secrets or environment variables. Users are advised to upgrade to version 0.12.39 as soon as possible. Users unable to upgrade should use a firewall blocking access to port 9777 from all untrusted network machines.
CWE description: The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.
CVSS v3 base score: 9.8
Published: 2022-04-11T20:15Z
Repository: https://github.com/garden-io/garden
Language: TypeScript
Files changed: core/src/cli/cli.ts, core/src/constants.ts, core/src/events.ts, core/src/server/dashboard-event-stream.ts, core/src/server/server.ts, core/test/unit/src/cli/cli.ts, core/test/unit/src/server/dashboard-event-stream.ts, core/test/unit/src/server/server.ts, dashboard/src/api/actions.ts, dashboard/src/api/api.ts, dashboard/src/components/menu.tsx, dashboard/src/hooks.ts, dashboard/src/util/helpers.ts
Commit date: 2022-04-07 20:47:09 +0000
Commit message: fix(core): require auth key for server endpoints

Diff: diff --git a/core/src/cli/cli.ts b/core/src/cli/cli.ts
index de359594..90f8e62c 100644
--- a/core/src/cli/cli.ts
+++ b/core/src/cli/cli.ts
@@ -433,11 +433,15 @@ ${renderCommands(commands)}
               projectName: garden.projectName,
               environmentName: garden.environmentName,
               namespace: garden.namespace,
             })
 
-            command.server.showUrl(dashboardProcess?.serverHost || undefined)
+            let url: string | undefined
+            if (dashboardProcess) {
+              url = `${dashboardProcess.serverHost}?key=${dashboardProcess.serverAuthKey}`
+            }
+            command.server.showUrl(url)
           }
         }
 
         if (cloudApi) {
           log.silly(`Connecting Garden instance to GE BufferedEventStream`)
diff --git a/core/src/constants.ts b/core/src/constants.ts
index 97ceb547..4a5f9e5c 100644
--- a/core/src/constants.ts
+++ b/core/src/constants.ts
@@ -69,10 +69,11 @@ export const gardenEnv = {
   GARDEN_LEGACY_BUILD_STAGE: env.get("GARDEN_LEGACY_BUILD_STAGE").required(false).asBool(),
   GARDEN_LOG_LEVEL: env.get("GARDEN_LOG_LEVEL").required(false).asString(),
   GARDEN_LOGGER_TYPE: env.get("GARDEN_LOGGER_TYPE").required(false).asString(),
   GARDEN_PROXY_DEFAULT_ADDRESS: env.get("GARDEN_PROXY_DEFAULT_ADDRESS").required(false).asString(),
   GARDEN_SERVER_PORT: env.get("GARDEN_SERVER_PORT").required(false).asPortNumber(),
+  GARDEN_SERVER_HOSTNAME: env.get("GARDEN_SERVER_HOSTNAME").required(false).asUrlString(),
   GARDEN_SKIP_TESTS: env.get("GARDEN_SKIP_TESTS").required(false).default("").asString(),
   GARDEN_HARD_CONCURRENCY_LIMIT: env.get("GARDEN_HARD_CONCURRENCY_LIMIT").required(false).default(50).asInt(),
   GARDEN_TASK_CONCURRENCY_LIMIT: env.get("GARDEN_TASK_CONCURRENCY_LIMIT").required(false).default(6).asInt(),
   GARDEN_WORKFLOW_RUN_UID: env.get("GARDEN_WORKFLOW_RUN_UID").required(false).asString(),
 }
diff --git a/core/src/events.ts b/core/src/events.ts
index 38962e93..2c8b222a 100644
--- a/core/src/events.ts
+++ b/core/src/events.ts
@@ -96,11 +96,11 @@ export interface Events extends LoggerEvents {
     workflowRunUid: string
   }
 
   // Process events
   serversUpdated: {
-    servers: { host: string; command: string }[]
+    servers: { host: string; command: string; serverAuthKey: string }[]
   }
   receivedToken: AuthTokenResponse
 
   // Session events - one of these is emitted when the command process ends
   sessionCompleted: {} // Command exited with a 0 status
diff --git a/core/src/server/dashboard-event-stream.ts b/core/src/server/dashboard-event-stream.ts
index e5aa4b69..3e3dec08 100644
--- a/core/src/server/dashboard-event-stream.ts
+++ b/core/src/server/dashboard-event-stream.ts
@@ -60,11 +60,15 @@ export class DashboardEventStream extends BufferedEventStream {
     // Notify of updates
     if (this.garden && !isEqual(currentHosts, newHosts)) {
       this.log.debug(`Updated list of running dashboard servers: ${servers.map((p) => p.serverHost).join(", ")}`)
 
       this.garden.events.emit("serversUpdated", {
-        servers: servers.map((p) => ({ command: p.command!, host: p.serverHost! })),
+        servers: servers.map((p) => ({
+          command: p.command!,
+          host: p.serverHost!,
+          serverAuthKey: p.serverAuthKey || "",
+        })),
       })
     }
 
     return servers
   }
diff --git a/core/src/server/server.ts b/core/src/server/server.ts
index a9e934f0..88ac2a92 100644
--- a/core/src/server/server.ts
+++ b/core/src/server/server.ts
@@ -77,19 +77,19 @@ export class GardenServer {
   constructor({ log, port }: { log: LogEntry; port?: number }) {
     this.log = log
     this.debugLog = this.log.placeholder({ level: LogLevel.debug, childEntriesInheritLevel: true })
     this.garden = undefined
     this.port = port
-    this.authKey = randomString(64)
+    this.authKey = randomString(24)
     this.incomingEvents = new EventBus()
     this.activePersistentRequests = {}
 
     this.serversUpdatedListener = ({ servers }) => {
       // Update status log line with new `garden dashboard` server, if any
-      for (const { host, command } of servers) {
+      for (const { host, command, serverAuthKey } of servers) {
         if (command === "dashboard") {
-          this.showUrl(host)
+          this.showUrl(`${host}?key=${serverAuthKey}`)
           return
         }
       }
 
       // No active explicit dashboard processes, show own URL instead
@@ -102,29 +102,35 @@ export class GardenServer {
       return
     }
 
     this.app = await this.createApp()
 
+    const hostname = gardenEnv.GARDEN_SERVER_HOSTNAME || "localhost"
+
     if (this.port) {
-      this.server = this.app.listen(this.port)
+      this.server = this.app.listen(this.port, hostname)
     } else {
       do {
         try {
           this.port = await getPort({ port: defaultWatchServerPort })
-          this.server = this.app.listen(this.port)
+          this.server = this.app.listen(this.port, hostname)
         } catch {}
       } while (!this.server)
     }
 
     this.log.info("")
     this.statusLog = this.log.placeholder()
   }
 
-  getUrl() {
+  getBaseUrl() {
     return `http://localhost:${this.port}`
   }
 
+  getUrl() {
+    return `${this.getBaseUrl()}?key=${this.authKey}`
+  }
+
   showUrl(url?: string) {
     this.statusLog.setState({
       emoji: "sunflower",
       msg: chalk.cyan("Garden dashboard running at ") + (url || this.getUrl()),
     })
@@ -154,19 +160,28 @@ export class GardenServer {
     const commands = prepareCommands()
 
     const app = websockify(new Koa())
     const http = new Router()
 
+    http.use((ctx, next) => {
+      const authToken = ctx.header[authTokenHeader] || ctx.query.key
+
+      if (authToken !== this.authKey) {
+        ctx.throw(401, `Unauthorized request`)
+        return
+      }
+      return next()
+    })
+
     /**
      * HTTP API endpoint (POST /api)
      *
      * We don't expose a different route per command, but rather accept a JSON object via POST on /api
      * with a `command` key. The API wouldn't be RESTful in any meaningful sense anyway, and this
      * means we can keep a consistent format across mechanisms.
      */
     http.post("/api", async (ctx) => {
-      // TODO: require auth key here from 0.13.0 onwards
       if (!this.garden) {
         return this.notReady(ctx)
       }
 
       if (!this.analytics) {
@@ -237,29 +252,22 @@ export class GardenServer {
      * Requires a valid auth token header, matching `this.authKey`.
      *
      * The API matches that of the Garden Cloud /events endpoint.
      */
     http.post("/events", async (ctx) => {
-      const authHeader = ctx.header[authTokenHeader]
-
-      if (authHeader !== this.authKey) {
-        ctx.status = 401
-        return
-      }
-
       // TODO: validate the input
-
       const batch = ctx.request.body as ApiEventBatch
       this.debugLog.debug(`Received ${batch.events.length} events from session ${batch.sessionId}`)
 
       // Pipe the events to the incoming stream, which websocket listeners will then receive
       batch.events.forEach((e) => this.incomingEvents.emit(e.name, e.payload))
 
       ctx.status = 200
     })
 
     app.use(bodyParser())
+
     app.use(http.routes())
     app.use(http.allowedMethods())
 
     app.on("error", (err, ctx) => {
       this.debugLog.info(`API server request failed with status ${ctx.status}: ${err.message}`)
@@ -275,11 +283,11 @@ export class GardenServer {
     this.addWebsocketEndpoint(app)
 
     return app
   }
 
-  private notReady(ctx: Router.IRouterContext) {
+  private notReady(ctx: Router.IRouterContext | Koa.ParameterizedContext) {
     ctx.status = 503
     ctx.response.body = notReadyMessage
   }
 
   /**
@@ -295,12 +303,10 @@ export class GardenServer {
         return this.notReady(ctx)
       }
 
       const connId = uuidv4()
 
-      // TODO: require auth key on connections here, from 0.13.0 onwards
-
       // The typing for koa-websocket isn't working currently
       const websocket: Koa.Context["ws"] = ctx["websocket"]
 
       // Helper to make JSON messages, make them type-safe, and to log errors.
       const send = <T extends ServerWebsocketMessageType>(type: T, payload: ServerWebsocketMessages[T]) => {
@@ -316,10 +322,17 @@ export class GardenServer {
       const error = (message: string, requestId?: string) => {
         this.log.debug(message)
         return send("error", { message, requestId })
       }
 
+      // TODO: Only allow auth key authentication
+      if (ctx.query.sessionId !== `${this.garden.sessionId}` && ctx.query.key !== `${this.authKey}`) {
+        error(`401 Unauthorized`)
+        websocket.terminate()
+        return
+      }
+
       // Set up heartbeat to detect dead connections
       let isAlive = true
 
       let heartbeatInterval = setInterval(() => {
         if (!isAlive) {
diff --git a/core/test/unit/src/cli/cli.ts b/core/test/unit/src/cli/cli.ts
index c18eae80..4c602829 100644
--- a/core/test/unit/src/cli/cli.ts
+++ b/core/test/unit/src/cli/cli.ts
@@ -418,11 +418,11 @@ describe("cli", () => {
       const record = await GardenProcess.register(["dashboard"])
       await record.setCommand({
         command: "dashboard",
         sessionId: serverGarden.sessionId,
         persistent: true,
-        serverHost: server.getUrl(),
+        serverHost: server.getBaseUrl(),
         serverAuthKey: server.authKey,
         projectRoot: serverGarden.projectRoot,
         projectName: serverGarden.projectName,
         environmentName: serverGarden.environmentName,
         namespace: serverGarden.namespace,
@@ -525,11 +525,11 @@ describe("cli", () => {
       const record = await GardenProcess.register(["dashboard"])
       await record.setCommand({
         command: "dashboard",
         sessionId: serverGarden.sessionId,
         persistent: true,
-        serverHost: server.getUrl(),
+        serverHost: server.getBaseUrl(),
         serverAuthKey: server.authKey,
         projectRoot: serverGarden.projectRoot,
         projectName: serverGarden.projectName,
         environmentName: serverGarden.environmentName,
         namespace: serverGarden.namespace,
diff --git a/core/test/unit/src/server/dashboard-event-stream.ts b/core/test/unit/src/server/dashboard-event-stream.ts
index 6739568f..515798cd 100644
--- a/core/test/unit/src/server/dashboard-event-stream.ts
+++ b/core/test/unit/src/server/dashboard-event-stream.ts
@@ -68,12 +68,12 @@ describe("DashboardEventStream", () => {
     streamer.connect({
       garden,
       streamEvents: true,
       streamLogEntries: true,
       targets: [
-        { host: serverA.getUrl(), clientAuthToken: serverA.authKey, enterprise: false },
-        { host: serverB.getUrl(), clientAuthToken: serverB.authKey, enterprise: false },
+        { host: serverA.getBaseUrl(), clientAuthToken: serverA.authKey, enterprise: false },
+        { host: serverB.getBaseUrl(), clientAuthToken: serverB.authKey, enterprise: false },
       ],
     })
 
     garden.events.emit("_test", "foo")
 
@@ -191,11 +191,13 @@ describe("DashboardEventStream", () => {
 
       await streamer.updateTargets()
       await record.setCommand(values)
       await streamer.updateTargets()
 
-      garden.events.expectEvent("serversUpdated", { servers: [{ host: values.serverHost, command: "dashboard" }] })
+      garden.events.expectEvent("serversUpdated", {
+        servers: [{ host: values.serverHost, command: "dashboard", serverAuthKey: "foo" }],
+      })
     })
 
     it("ignores servers matching ignoreHost", async () => {
       const record = await GardenProcess.register([testArg])
       const values = {
diff --git a/core/test/unit/src/server/server.ts b/core/test/unit/src/server/server.ts
index 7147a1ed..6b162948 100644
--- a/core/test/unit/src/server/server.ts
+++ b/core/test/unit/src/server/server.ts
@@ -56,51 +56,87 @@ describe("GardenServer", () => {
   })
 
   it("should update dashboard URL with new one if another is started", async () => {
     gardenServer.showUrl("http://foo")
     garden.events.emit("serversUpdated", {
-      servers: [{ host: "http://localhost:9800", command: "dashboard" }],
+      servers: [{ host: "http://localhost:9800", command: "dashboard", serverAuthKey: "foo" }],
     })
     const line = gardenServer["statusLog"]
     await sleep(1) // This is enough to let go of the control loop
     const status = stripAnsi(line.getLatestMessage().msg || "")
-    expect(status).to.equal(`Garden dashboard running at http://localhost:9800`)
+    expect(status).to.equal(`Garden dashboard running at http://localhost:9800?key=foo`)
   })
 
   describe("GET /", () => {
     it("should return the dashboard index page", async () => {
-      await request(server).get("/").expect(200)
+      await request(server)
+        .get("/")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .expect(200)
     })
   })
 
   describe("POST /api", () => {
+    it("returns 401 if missing auth header", async () => {
+      await request(server).post("/api").send({}).expect(401)
+    })
+
+    it("returns 401 if auth header doesn't match auth key", async () => {
+      await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: "foo" })
+        .send({})
+        .expect(401)
+    })
+
     it("should 400 on non-JSON body", async () => {
-      await request(server).post("/api").send("foo").expect(400)
+      await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .send("foo")
+        .expect(400)
     })
 
     it("should 400 on invalid payload", async () => {
-      await request(server).post("/api").send({ foo: "bar" }).expect(400)
+      await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .send({ foo: "bar" })
+        .expect(400)
     })
 
     it("should 404 on invalid command", async () => {
-      await request(server).post("/api").send({ command: "foo" }).expect(404)
+      await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .send({ command: "foo" })
+        .expect(404)
     })
 
     it("should 503 when Garden instance is not set", async () => {
       gardenServer["garden"] = undefined
-      await request(server).post("/api").send({ command: "get.config" }).expect(503)
+      await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .send({ command: "get.config" })
+        .expect(503)
     })
 
     it("should execute a command and return its results", async () => {
-      const res = await request(server).post("/api").send({ command: "get.config" }).expect(200)
+      const res = await request(server)
+        .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .send({ command: "get.config" })
+        .expect(200)
       const config = await garden.dumpConfig({ log: garden.log })
       expect(res.body.result).to.eql(deepOmitUndefined(config))
     })
 
     it("should correctly map arguments and options to commands", async () => {
       const res = await request(server)
         .post("/api")
+        .set({ [authTokenHeader]: gardenServer.authKey })
         .send({
           command: "build",
           parameters: {
             modules: ["module-a"],
             force: true,
@@ -117,12 +153,27 @@ describe("GardenServer", () => {
       })
     })
   })
 
   describe("/dashboardPages", () => {
+    it("returns 401 if missing auth header", async () => {
+      await request(server).get("/dashboardPages/test-plugin/test").expect(401)
+    })
+
+    it("returns 401 if auth header doesn't match auth key", async () => {
+      await request(server)
+        .get("/dashboardPages/test-plugin/test")
+        .set({ [authTokenHeader]: "foo" })
+        .send({})
+        .expect(401)
+    })
+
     it("should resolve the URL for the given dashboard page and redirect", async () => {
-      const res = await request(server).get("/dashboardPages/test-plugin/test").expect(302)
+      const res = await request(server)
+        .get("/dashboardPages/test-plugin/test")
+        .set({ [authTokenHeader]: gardenServer.authKey })
+        .expect(302)
 
       expect(res.header.location).to.equal("http://localhost:12345/test")
     })
   })
 
@@ -160,11 +211,11 @@ describe("GardenServer", () => {
 
   describe("/ws", () => {
     let ws: WebSocket
 
     beforeEach((done) => {
-      ws = new WebSocket(`ws://localhost:${port}/ws`)
+      ws = new WebSocket(`ws://localhost:${port}/ws?sessionId=${garden.sessionId}`)
       ws.on("open", () => {
         done()
       })
       ws.on("error", done)
     })
@@ -175,10 +226,38 @@ describe("GardenServer", () => {
 
     const onMessage = (cb: (req: object) => void) => {
       ws.on("message", (msg) => cb(JSON.parse(msg.toString())))
     }
 
+    it("terminates the connection if auth query params are missing", (done) => {
+      const badWs = new WebSocket(`ws://localhost:${port}/ws`)
+      badWs.on("close", () => {
+        done()
+      })
+    })
+
+    it("terminates the connection if key doesn't match and sessionId is missing", (done) => {
+      const badWs = new WebSocket(`ws://localhost:${port}/ws?key=foo`)
+      badWs.on("close", () => {
+        done()
+      })
+    })
+
+    it("terminates the connection if sessionId doesn't match and key is missing", (done) => {
+      const badWs = new WebSocket(`ws://localhost:${port}/ws?sessionId=foo`)
+      badWs.on("close", () => {
+        done()
+      })
+    })
+
+    it("terminates the connection if both sessionId and key are bad", (done) => {
+      const badWs = new WebSocket(`ws://localhost:${port}/ws?sessionId=foo&key=bar`)
+      badWs.on("close", () => {
+        done()
+      })
+    })
+
     it("should emit events from the Garden event bus", (done) => {
       onMessage((req) => {
         expect(req).to.eql({ type: "event", name: "_test", payload: "foo" })
         done()
       })
diff --git a/dashboard/src/api/actions.ts b/dashboard/src/api/actions.ts
index f8eb9a48..6afdc552 100644
--- a/dashboard/src/api/actions.ts
+++ b/dashboard/src/api/actions.ts
@@ -34,11 +34,11 @@ import {
   fetchTestResult,
   fetchGraph,
   FetchTaskResultParams,
   FetchTestResultParams,
 } from "./api"
-import { getTestKey } from "../util/helpers"
+import { getAuthKey, getTestKey } from "../util/helpers"
 import { ProviderMap } from "@garden-io/core/build/src/config/provider"
 import { DashboardPage } from "@garden-io/core/build/src/types/plugin/provider/getDashboardPage"
 
 // This file contains the API action functions.
 // The actions are responsible for dispatching the appropriate action types and normalising the
@@ -111,11 +111,11 @@ function processConfigInitResult(entities: Entities, config: ConfigDump) {
         ...page,
         providerName: provider.name,
         path: `/provider/${provider.name}/${page.name}`,
         description: page.description + ` (from provider ${provider.name})`,
         // Use static URL if provided, otherwise we'll request a redirect from this API endpoint
-        url: page.url || `/dashboardPages/${provider.name}/${page.name}`,
+        url: page.url || `/dashboardPages/${provider.name}/${page.name}?key=${getAuthKey()}`,
       }))
     })
 
     for (const cfg of config.moduleConfigs) {
       const module: ModuleEntity = {
diff --git a/dashboard/src/api/api.ts b/dashboard/src/api/api.ts
index ea123a1c..a72af5e5 100644
--- a/dashboard/src/api/api.ts
+++ b/dashboard/src/api/api.ts
@@ -13,10 +13,11 @@ import { GetTaskResultCommandResult } from "@garden-io/core/build/src/commands/g
 import { GetTestResultCommandResult } from "@garden-io/core/build/src/commands/get/get-test-result"
 import { ServiceLogEntry } from "@garden-io/core/build/src/types/plugin/service/getServiceLogs"
 import { CommandResult } from "@garden-io/core/build/src/commands/base"
 import { ConfigDump } from "@garden-io/core/build/src/garden"
 import { StatusCommandResult } from "@garden-io/core/build/src/commands/get/get-status"
+import { getAuthKey } from "../util/helpers"
 
 export interface ApiRequest {
   command: string
   parameters: {}
 }
@@ -62,11 +63,12 @@ export async function fetchTestResult({ name, moduleName }: FetchTestResultParam
 }
 
 async function apiCommand<T>(command: string, parameters: {} = {}): Promise<T> {
   const url = "/api"
   const method = "POST"
-  const headers = { "Content-Type": "application/json" }
+  const headers = { "Content-Type": "application/json", "x-access-auth-token": getAuthKey() }
+
   const data: ApiRequest = { command, parameters }
 
   const res = await axios.request<CommandResult<T>>({ url, method, headers, data })
 
   if (res.data.errors) {
diff --git a/dashboard/src/components/menu.tsx b/dashboard/src/components/menu.tsx
index 980ed622..fd4cbb88 100644
--- a/dashboard/src/components/menu.tsx
+++ b/dashboard/src/components/menu.tsx
@@ -7,17 +7,18 @@
  */
 
 import { css } from "emotion"
 import styled from "@emotion/styled"
 import React from "react"
-import { NavLink as ReactRouterNavLink } from "react-router-dom"
+import { NavLink as ReactRouterNavLink, NavLinkProps as ReactRouterNavLinkProps } from "react-router-dom"
 
 import logo from "../assets/logo.png"
 
 import { colors } from "../styles/variables"
 import { useUiState } from "../hooks"
 import { Page } from "../contexts/api"
+import { getAuthKey } from "../util/helpers"
 
 interface Props {
   pages: Page[]
 }
 
@@ -44,11 +45,32 @@ const Nav = styled.nav`
   align-items: center;
   height: 100%;
   width: 100%;
 `
 
-const NavLink = (props: any) => <ReactRouterNavLink {...props} activeStyle={{ color: colors.gardenGreenDark }} />
+type NavLinkProps = Omit<ReactRouterNavLinkProps, "to"> & {
+  to: {
+    pathname: string
+    search?: string
+    hash?: string
+    state?: any
+  }
+}
+
+/**
+ * Helper class for ensuring that the auth key param is set on routes.
+ */
+const NavLink = (props: NavLinkProps) => {
+  const urlParams = new URLSearchParams(props.to.search)
+  urlParams.set("key", getAuthKey() || "")
+  const to = {
+    ...props.to,
+    search: urlParams.toString(),
+  }
+
+  return <ReactRouterNavLink {...props} to={to} activeStyle={{ color: colors.gardenGreenDark }} />
+}
 
 const A = styled.a(linkStyle)
 const Link = styled(NavLink)(linkStyle)
 
 // Style and align properly
@@ -75,11 +97,11 @@ const Menu: React.FC<Props> = ({ pages }) => {
 
   return (
     <>
       <MenuContainer visible={isMenuOpen}>
         <Nav>
-          <NavLink to="/">
+          <NavLink to={{ pathname: "/" }}>
             <Logo src={logo} alt="Home" />
           </NavLink>
           {pages.map((page) => (
             <MenuButton key={page.path} page={page} />
           ))}
diff --git a/dashboard/src/hooks.ts b/dashboard/src/hooks.ts
index fd175480..01a5c9f0 100644
--- a/dashboard/src/hooks.ts
+++ b/dashboard/src/hooks.ts
@@ -9,10 +9,11 @@
 import { useState, useEffect, useCallback, useRef, useContext } from "react"
 
 import getApiUrl from "./api/get-api-url"
 import { ApiContext } from "./contexts/api"
 import { UiContext } from "./contexts/ui"
+import { getAuthKey } from "./util/helpers"
 
 const wsRetryInterval = 2000
 
 type WsConnectionState = "open" | "closed"
 
@@ -34,11 +35,11 @@ export const useWebsocket = (
     setWsState("closed")
   }
 
   const initWs = () => {
     const url = getApiUrl()
-    const ws = new WebSocket(`ws://${url}/ws`)
+    const ws = new WebSocket(`ws://${url}/ws?key=${getAuthKey()}`)
     ws.onopen = (_event) => {
       if (wsStateRef.current === "closed") {
         handleWsOpened()
       }
       setWsState("open")
diff --git a/dashboard/src/util/helpers.ts b/dashboard/src/util/helpers.ts
index 88562e93..36724b3c 100644
--- a/dashboard/src/util/helpers.ts
+++ b/dashboard/src/util/helpers.ts
@@ -94,5 +94,13 @@ export function getTextWidth(text: string, font: string) {
   const context = canvas.getContext("2d")!
   context.font = font
   const metrics = context.measureText(text)
   return metrics.width
 }
+
+/**
+ * Returns the auth key from the current page's URL params.
+ */
+export function getAuthKey() {
+  const urlParams = new URLSearchParams(window.location.search)
+  return urlParams.get("key")
+}

Vulnerable code:             command.server.showUrl(dashboardProcess?.serverHost || undefined)
    servers: { host: string; command: string }[]
        servers: servers.map((p) => ({ command: p.command!, host: p.serverHost! })),
    this.authKey = randomString(64)
      for (const { host, command } of servers) {
          this.showUrl(host)
      this.server = this.app.listen(this.port)
          this.server = this.app.listen(this.port)
  getUrl() {
      // TODO: require auth key here from 0.13.0 onwards
      const authHeader = ctx.header[authTokenHeader]

      if (authHeader !== this.authKey) {
        ctx.status = 401
        return
      }


  private notReady(ctx: Router.IRouterContext) {
      // TODO: require auth key on connections here, from 0.13.0 onwards

        serverHost: server.getUrl(),
        serverHost: server.getUrl(),
        { host: serverA.getUrl(), clientAuthToken: serverA.authKey, enterprise: false },
        { host: serverB.getUrl(), clientAuthToken: serverB.authKey, enterprise: false },
      garden.events.expectEvent("serversUpdated", { servers: [{ host: values.serverHost, command: "dashboard" }] })
      servers: [{ host: "http://localhost:9800", command: "dashboard" }],
    expect(status).to.equal(`Garden dashboard running at http://localhost:9800`)
      await request(server).get("/").expect(200)
      await request(server).post("/api").send("foo").expect(400)
      await request(server).post("/api").send({ foo: "bar" }).expect(400)
      await request(server).post("/api").send({ command: "foo" }).expect(404)
      await request(server).post("/api").send({ command: "get.config" }).expect(503)
      const res = await request(server).post("/api").send({ command: "get.config" }).expect(200)
      const res = await request(server).get("/dashboardPages/test-plugin/test").expect(302)
      ws = new WebSocket(`ws://localhost:${port}/ws`)
import { getTestKey } from "../util/helpers"
        url: page.url || `/dashboardPages/${provider.name}/${page.name}`,
  const headers = { "Content-Type": "application/json" }
import { NavLink as ReactRouterNavLink } from "react-router-dom"
const NavLink = (props: any) => <ReactRouterNavLink {...props} activeStyle={{ color: colors.gardenGreenDark }} />
          <NavLink to="/">
    const ws = new WebSocket(`ws://${url}/ws`)
Fixed code:             let url: string | undefined
            if (dashboardProcess) {
              url = `${dashboardProcess.serverHost}?key=${dashboardProcess.serverAuthKey}`
            }
            command.server.showUrl(url)
  GARDEN_SERVER_HOSTNAME: env.get("GARDEN_SERVER_HOSTNAME").required(false).asUrlString(),
    servers: { host: string; command: string; serverAuthKey: string }[]
        servers: servers.map((p) => ({
          command: p.command!,
          host: p.serverHost!,
          serverAuthKey: p.serverAuthKey || "",
        })),
    this.authKey = randomString(24)
      for (const { host, command, serverAuthKey } of servers) {
          this.showUrl(`${host}?key=${serverAuthKey}`)
    const hostname = gardenEnv.GARDEN_SERVER_HOSTNAME || "localhost"

      this.server = this.app.listen(this.port, hostname)
          this.server = this.app.listen(this.port, hostname)
  getBaseUrl() {
  getUrl() {
    return `${this.getBaseUrl()}?key=${this.authKey}`
  }

    http.use((ctx, next) => {
      const authToken = ctx.header[authTokenHeader] || ctx.query.key

      if (authToken !== this.authKey) {
        ctx.throw(401, `Unauthorized request`)
        return
      }
      return next()
    })


  private notReady(ctx: Router.IRouterContext | Koa.ParameterizedContext) {
      // TODO: Only allow auth key authentication
      if (ctx.query.sessionId !== `${this.garden.sessionId}` && ctx.query.key !== `${this.authKey}`) {
        error(`401 Unauthorized`)
        websocket.terminate()
        return
      }

        serverHost: server.getBaseUrl(),
        serverHost: server.getBaseUrl(),
        { host: serverA.getBaseUrl(), clientAuthToken: serverA.authKey, enterprise: false },
        { host: serverB.getBaseUrl(), clientAuthToken: serverB.authKey, enterprise: false },
      garden.events.expectEvent("serversUpdated", {
        servers: [{ host: values.serverHost, command: "dashboard", serverAuthKey: "foo" }],
      })
      servers: [{ host: "http://localhost:9800", command: "dashboard", serverAuthKey: "foo" }],
    expect(status).to.equal(`Garden dashboard running at http://localhost:9800?key=foo`)
      await request(server)
        .get("/")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .expect(200)
    it("returns 401 if missing auth header", async () => {
      await request(server).post("/api").send({}).expect(401)
    })

    it("returns 401 if auth header doesn't match auth key", async () => {
      await request(server)
        .post("/api")
        .set({ [authTokenHeader]: "foo" })
        .send({})
        .expect(401)
    })

      await request(server)
        .post("/api")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .send("foo")
        .expect(400)
      await request(server)
        .post("/api")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .send({ foo: "bar" })
        .expect(400)
      await request(server)
        .post("/api")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .send({ command: "foo" })
        .expect(404)
      await request(server)
        .post("/api")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .send({ command: "get.config" })
        .expect(503)
      const res = await request(server)
        .post("/api")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .send({ command: "get.config" })
        .expect(200)
        .set({ [authTokenHeader]: gardenServer.authKey })
    it("returns 401 if missing auth header", async () => {
      await request(server).get("/dashboardPages/test-plugin/test").expect(401)
    })

    it("returns 401 if auth header doesn't match auth key", async () => {
      await request(server)
        .get("/dashboardPages/test-plugin/test")
        .set({ [authTokenHeader]: "foo" })
        .send({})
        .expect(401)
    })

      const res = await request(server)
        .get("/dashboardPages/test-plugin/test")
        .set({ [authTokenHeader]: gardenServer.authKey })
        .expect(302)
      ws = new WebSocket(`ws://localhost:${port}/ws?sessionId=${garden.sessionId}`)
    it("terminates the connection if auth query params are missing", (done) => {
      const badWs = new WebSocket(`ws://localhost:${port}/ws`)
      badWs.on("close", () => {
        done()
      })
    })

    it("terminates the connection if key doesn't match and sessionId is missing", (done) => {
      const badWs = new WebSocket(`ws://localhost:${port}/ws?key=foo`)
      badWs.on("close", () => {
        done()
      })
    })

    it("terminates the connection if sessionId doesn't match and key is missing", (done) => {
      const badWs = new WebSocket(`ws://localhost:${port}/ws?sessionId=foo`)
      badWs.on("close", () => {
        done()
      })
    })

    it("terminates the connection if both sessionId and key are bad", (done) => {
      const badWs = new WebSocket(`ws://localhost:${port}/ws?sessionId=foo&key=bar`)
      badWs.on("close", () => {
        done()
      })
    })

import { getAuthKey, getTestKey } from "../util/helpers"
        url: page.url || `/dashboardPages/${provider.name}/${page.name}?key=${getAuthKey()}`,
import { getAuthKey } from "../util/helpers"
  const headers = { "Content-Type": "application/json", "x-access-auth-token": getAuthKey() }

import { NavLink as ReactRouterNavLink, NavLinkProps as ReactRouterNavLinkProps } from "react-router-dom"
import { getAuthKey } from "../util/helpers"
type NavLinkProps = Omit<ReactRouterNavLinkProps, "to"> & {
  to: {
    pathname: string
    search?: string
    hash?: string
    state?: any
  }
}

/**
 * Helper class for ensuring that the auth key param is set on routes.
 */
const NavLink = (props: NavLinkProps) => {
  const urlParams = new URLSearchParams(props.to.search)
  urlParams.set("key", getAuthKey() || "")
  const to = {
    ...props.to,
    search: urlParams.toString(),
  }

  return <ReactRouterNavLink {...props} to={to} activeStyle={{ color: colors.gardenGreenDark }} />
}
          <NavLink to={{ pathname: "/" }}>
import { getAuthKey } from "./util/helpers"
    const ws = new WebSocket(`ws://${url}/ws?key=${getAuthKey()}`)

/**
 * Returns the auth key from the current page's URL params.
 */
export function getAuthKey() {
  const urlParams = new URLSearchParams(window.location.search)
  return urlParams.get("key")
}