From ed43ffe772435ccd42b436e512943959f499a976 Mon Sep 17 00:00:00 2001 From: eddymarc <132223353+Edd88-pixel@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:38:02 +0100 Subject: [PATCH 1/4] fix(jupyter): bind notebook services to loopback --- .../coder/modules/jupyter-notebook/README.md | 24 +++++- .../modules/jupyter-notebook/main.test.ts | 86 +++++++++++++++++++ .../coder/modules/jupyter-notebook/main.tf | 12 +++ .../modules/jupyter-notebook/main.tftest.hcl | 54 ++++++++++++ .../coder/modules/jupyter-notebook/run.sh | 8 +- registry/coder/modules/jupyterlab/README.md | 26 +++++- .../coder/modules/jupyterlab/main.test.ts | 70 ++++++++++++++- registry/coder/modules/jupyterlab/main.tf | 12 +++ .../coder/modules/jupyterlab/main.tftest.hcl | 84 ++++++++++++++++++ registry/coder/modules/jupyterlab/run.sh | 6 +- 10 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 registry/coder/modules/jupyter-notebook/main.test.ts create mode 100644 registry/coder/modules/jupyter-notebook/main.tftest.hcl create mode 100644 registry/coder/modules/jupyterlab/main.tftest.hcl diff --git a/registry/coder/modules/jupyter-notebook/README.md b/registry/coder/modules/jupyter-notebook/README.md index ea625d812..7de146d4d 100644 --- a/registry/coder/modules/jupyter-notebook/README.md +++ b/registry/coder/modules/jupyter-notebook/README.md @@ -12,11 +12,33 @@ A module that adds Jupyter Notebook in your Coder template. ![Jupyter Notebook](../../.images/jupyter-notebook.png) +Jupyter Notebook listens on `127.0.0.1` by default so that unauthenticated +traffic must pass through Coder's application proxy. + +## Usage + +```tf +module "jupyter-notebook" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jupyter-notebook/coder" + version = "1.3.0" + agent_id = coder_agent.main.id +} +``` + +## External network access + +> [!WARNING] +> For advanced environments that require direct network access, set `host` +> explicitly. Binding to `0.0.0.0` exposes the unauthenticated service to every +> reachable network interface and is less secure. + ```tf module "jupyter-notebook" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyter-notebook/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id + host = "0.0.0.0" } ``` diff --git a/registry/coder/modules/jupyter-notebook/main.test.ts b/registry/coder/modules/jupyter-notebook/main.test.ts new file mode 100644 index 000000000..6c5f76e3d --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/main.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, setDefaultTimeout } from "bun:test"; +import { + execContainer, + findResourceInstance, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeFileContainer, +} from "~test"; + +setDefaultTimeout(30_000); + +describe("jupyter-notebook", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("binds to loopback by default", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const script = findResourceInstance(state, "coder_script").script; + + expect(script).toContain("--NotebookApp.ip='127.0.0.1'"); + expect(script).not.toContain("0.0.0.0"); + expect(script).not.toContain("--NotebookApp.ip='*'"); + expect(script).not.toContain("--ServerApp.ip='*'"); + + const id = await runContainer("alpine"); + try { + await execContainer(id, ["mkdir", "-p", "/root/.local/bin"]); + await writeFileContainer( + id, + "/root/.local/bin/jupyter-notebook", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > /tmp/jupyter-args\n", + ); + await execContainer(id, [ + "chmod", + "755", + "/root/.local/bin/jupyter-notebook", + ]); + const result = await execContainer( + id, + ["sh", "-c", script], + [ + "--env", + "PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ], + ); + expect(result.exitCode).toBe(0); + const args = await readFileContainer(id, "/tmp/jupyter-args"); + expect(args).toContain("--NotebookApp.ip=127.0.0.1"); + } finally { + await removeContainer(id); + } + }); + + it("renders an explicit external host", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + host: "0.0.0.0", + }); + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain("--NotebookApp.ip='0.0.0.0'"); + }); + + for (const unsafeHost of [ + "127.0.0.1; touch /tmp/injected", + "127.0.0.1 $(id)", + "127.0.0.1`id`", + "127.0.0.1'quoted", + ]) { + it(`rejects unsafe host ${JSON.stringify(unsafeHost)}`, async () => { + const apply = runTerraformApply(import.meta.dir, { + agent_id: "foo", + host: unsafeHost, + }); + await expect(apply).rejects.toThrow("host must contain only"); + }); + } +}); diff --git a/registry/coder/modules/jupyter-notebook/main.tf b/registry/coder/modules/jupyter-notebook/main.tf index 568e8ca7a..d1843bd0d 100644 --- a/registry/coder/modules/jupyter-notebook/main.tf +++ b/registry/coder/modules/jupyter-notebook/main.tf @@ -21,6 +21,17 @@ variable "log_path" { default = "/tmp/jupyter-notebook.log" } +variable "host" { + description = "The host address that Jupyter Notebook listens on." + type = string + default = "127.0.0.1" + + validation { + condition = can(regex("^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", var.host)) + error_message = "host must contain only letters, numbers, dots, and hyphens, and must start and end with a letter or number." + } +} + variable "port" { type = number description = "The port to run jupyter-notebook on." @@ -66,6 +77,7 @@ resource "coder_script" "jupyter-notebook" { icon = "/icon/jupyter.svg" script = templatefile("${path.module}/run.sh", { LOG_PATH : var.log_path, + HOST : var.host, PORT : var.port, REQUIREMENTS_PATH : var.requirements_path, PIP_INSTALL_EXTRA_PACKAGES : var.pip_install_extra_packages diff --git a/registry/coder/modules/jupyter-notebook/main.tftest.hcl b/registry/coder/modules/jupyter-notebook/main.tftest.hcl new file mode 100644 index 000000000..ffb1a779f --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/main.tftest.hcl @@ -0,0 +1,54 @@ +run "secure_defaults" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = var.host == "127.0.0.1" + error_message = "host must default to 127.0.0.1" + } + + assert { + condition = strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='127.0.0.1'") + error_message = "the default script must bind Jupyter Notebook to 127.0.0.1" + } + + assert { + condition = !strcontains(coder_script.jupyter-notebook.script, "0.0.0.0") && !strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='*'") && !strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='*'") + error_message = "the default script must not bind Jupyter Notebook to all interfaces" + } + + assert { + condition = coder_app.jupyter-notebook.url == "http://localhost:19999" + error_message = "the default Coder application URL must remain unchanged" + } +} + +run "explicit_external_host" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "0.0.0.0" + } + + assert { + condition = strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='0.0.0.0'") + error_message = "the script must render an explicitly configured host" + } +} + +run "unsafe_host_rejected" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "127.0.0.1; touch /tmp/injected" + } + + expect_failures = [ + var.host, + ] +} diff --git a/registry/coder/modules/jupyter-notebook/run.sh b/registry/coder/modules/jupyter-notebook/run.sh index 797d9b41f..36559ec86 100644 --- a/registry/coder/modules/jupyter-notebook/run.sh +++ b/registry/coder/modules/jupyter-notebook/run.sh @@ -40,4 +40,10 @@ fi echo "👷 Starting jupyter-notebook in background..." echo "check logs at ${LOG_PATH}" -$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & +"$HOME/.local/bin/jupyter-notebook" \ + --NotebookApp.ip='${HOST}' \ + --ServerApp.port='${PORT}' \ + --no-browser \ + --ServerApp.token='' \ + --ServerApp.password='' \ + > "${LOG_PATH}" 2>&1 & diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md index 0e2b7dcf8..bf0f77e19 100644 --- a/registry/coder/modules/jupyterlab/README.md +++ b/registry/coder/modules/jupyterlab/README.md @@ -12,12 +12,34 @@ A module that adds JupyterLab in your Coder template. ![JupyterLab](../../.images/jupyterlab.png) +JupyterLab listens on `127.0.0.1` by default so that unauthenticated traffic +must pass through Coder's application proxy. + +## Usage + +```tf +module "jupyterlab" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jupyterlab/coder" + version = "1.3.0" + agent_id = coder_agent.main.id +} +``` + +## External network access + +> [!WARNING] +> For advanced environments that require direct network access, set `host` +> explicitly. Binding to `0.0.0.0` exposes the unauthenticated service to every +> reachable network interface and is less secure. + ```tf module "jupyterlab" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyterlab/coder" - version = "1.2.2" + version = "1.3.0" agent_id = coder_agent.main.id + host = "0.0.0.0" } ``` @@ -29,7 +51,7 @@ JupyterLab is automatically configured to work with Coder's iframe embedding. Fo module "jupyterlab" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyterlab/coder" - version = "1.2.2" + version = "1.3.0" agent_id = coder_agent.main.id config = { ServerApp = { diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts index 681188ca3..721cfafb0 100644 --- a/registry/coder/modules/jupyterlab/main.test.ts +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, setDefaultTimeout } from "bun:test"; import { execContainer, executeScriptInContainer, @@ -10,8 +10,11 @@ import { runTerraformInit, testRequiredVariables, type TerraformState, + writeFileContainer, } from "~test"; +setDefaultTimeout(30_000); + // executes the coder script after installing pip const executeScriptInContainerWithPip = async ( state: TerraformState, @@ -166,4 +169,69 @@ describe("jupyterlab", async () => { ); expect(configScripts.length).toBe(1); }); + + it("binds to loopback by default without changing Coder URLs", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const script = findResourceInstance( + state, + "coder_script", + "jupyterlab", + ).script; + const app = findResourceInstance(state, "coder_app", "jupyterlab"); + + expect(script).toContain("--ServerApp.ip='127.0.0.1'"); + expect(script).not.toContain("0.0.0.0"); + expect(script).not.toContain("--ServerApp.ip='*'"); + expect(app.url).toBe("http://localhost:19999"); + + const id = await runContainer("alpine"); + try { + await writeFileContainer( + id, + "/usr/local/bin/jupyter-lab", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > /tmp/jupyter-args\n", + ); + await execContainer(id, ["chmod", "755", "/usr/local/bin/jupyter-lab"]); + const result = await execContainer(id, ["sh", "-c", script]); + expect(result.exitCode).toBe(0); + const args = await readFileContainer(id, "/tmp/jupyter-args"); + expect(args).toContain("--ServerApp.ip=127.0.0.1"); + } finally { + await removeContainer(id); + } + }); + + it("preserves path mode and renders an explicit external host", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + host: "0.0.0.0", + subdomain: false, + }); + const script = findResourceInstance( + state, + "coder_script", + "jupyterlab", + ).script; + + expect(script).toContain("--ServerApp.ip='0.0.0.0'"); + expect(script).toContain("--ServerApp.base_url=/@"); + expect(script).toContain("/apps/jupyterlab"); + }); + + for (const unsafeHost of [ + "127.0.0.1; touch /tmp/injected", + "127.0.0.1 $(id)", + "127.0.0.1`id`", + "127.0.0.1'quoted", + ]) { + it(`rejects unsafe host ${JSON.stringify(unsafeHost)}`, async () => { + const apply = runTerraformApply(import.meta.dir, { + agent_id: "foo", + host: unsafeHost, + }); + await expect(apply).rejects.toThrow("host must contain only"); + }); + } }); diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf index f2b308608..6d190f831 100644 --- a/registry/coder/modules/jupyterlab/main.tf +++ b/registry/coder/modules/jupyterlab/main.tf @@ -41,6 +41,17 @@ variable "log_path" { default = "/tmp/jupyterlab.log" } +variable "host" { + description = "The host address that JupyterLab listens on." + type = string + default = "127.0.0.1" + + validation { + condition = can(regex("^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", var.host)) + error_message = "host must contain only letters, numbers, dots, and hyphens, and must start and end with a letter or number." + } +} + variable "port" { type = number description = "The port to run jupyterlab on." @@ -100,6 +111,7 @@ resource "coder_script" "jupyterlab" { icon = "/icon/jupyter.svg" script = templatefile("${path.module}/run.sh", { LOG_PATH : var.log_path, + HOST : var.host, PORT : var.port BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" }) diff --git a/registry/coder/modules/jupyterlab/main.tftest.hcl b/registry/coder/modules/jupyterlab/main.tftest.hcl new file mode 100644 index 000000000..6fbc73647 --- /dev/null +++ b/registry/coder/modules/jupyterlab/main.tftest.hcl @@ -0,0 +1,84 @@ +run "secure_defaults" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = var.host == "127.0.0.1" + error_message = "host must default to 127.0.0.1" + } + + assert { + condition = strcontains(coder_script.jupyterlab.script, "--ServerApp.ip='127.0.0.1'") + error_message = "the default script must bind JupyterLab to 127.0.0.1" + } + + assert { + condition = !strcontains(coder_script.jupyterlab.script, "0.0.0.0") && !strcontains(coder_script.jupyterlab.script, "--ServerApp.ip='*'") + error_message = "the default script must not bind JupyterLab to all interfaces" + } + + assert { + condition = coder_app.jupyterlab.url == "http://localhost:19999" + error_message = "the default Coder application URL must remain unchanged" + } + + assert { + condition = one(coder_app.jupyterlab.healthcheck).url == "http://localhost:19999/api" + error_message = "the JupyterLab healthcheck URL must remain unchanged" + } +} + +run "path_mode_and_external_host" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "0.0.0.0" + subdomain = false + } + + override_data { + target = data.coder_workspace.me + values = { + name = "example-workspace" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + name = "example-owner" + } + } + + assert { + condition = strcontains(coder_script.jupyterlab.script, "--ServerApp.ip='0.0.0.0'") + error_message = "the script must render an explicitly configured host" + } + + assert { + condition = strcontains(coder_script.jupyterlab.script, "--ServerApp.base_url=/@example-owner/example-workspace/apps/jupyterlab") + error_message = "the JupyterLab base URL must remain unchanged in path mode" + } + + assert { + condition = coder_app.jupyterlab.url == "http://localhost:19999/@example-owner/example-workspace/apps/jupyterlab" + error_message = "the JupyterLab application URL must remain unchanged in path mode" + } +} + +run "unsafe_host_rejected" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "127.0.0.1; touch /tmp/injected" + } + + expect_failures = [ + var.host, + ] +} diff --git a/registry/coder/modules/jupyterlab/run.sh b/registry/coder/modules/jupyterlab/run.sh index 5edf35ef6..626a5dba1 100644 --- a/registry/coder/modules/jupyterlab/run.sh +++ b/registry/coder/modules/jupyterlab/run.sh @@ -49,10 +49,10 @@ fi printf "👷 Starting jupyterlab in background..." printf "check logs at ${LOG_PATH}" -$JUPYTER --no-browser \ +"$JUPYTER" --no-browser \ "$BASE_URL_FLAG" \ - --ServerApp.ip='*' \ - --ServerApp.port="${PORT}" \ + --ServerApp.ip='${HOST}' \ + --ServerApp.port='${PORT}' \ --ServerApp.token='' \ --ServerApp.password='' \ > "${LOG_PATH}" 2>&1 & From cdd85dc3daf9b09326ca16c263958509451b0be3 Mon Sep 17 00:00:00 2001 From: eddymarc <132223353+Edd88-pixel@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:35:10 +0100 Subject: [PATCH 2/4] fix(jupyter): address review feedback --- .../modules/jupyter-notebook/main.test.ts | 18 ++++++++++++--- .../coder/modules/jupyter-notebook/main.tf | 4 ++-- .../modules/jupyter-notebook/main.tftest.hcl | 23 +++++++++++++++++-- .../coder/modules/jupyter-notebook/run.sh | 3 ++- .../coder/modules/jupyterlab/main.test.ts | 3 +++ registry/coder/modules/jupyterlab/main.tf | 4 ++-- .../coder/modules/jupyterlab/main.tftest.hcl | 19 +++++++++++++++ registry/coder/modules/jupyterlab/run.sh | 1 + 8 files changed, 65 insertions(+), 10 deletions(-) diff --git a/registry/coder/modules/jupyter-notebook/main.test.ts b/registry/coder/modules/jupyter-notebook/main.test.ts index 6c5f76e3d..d0028cedc 100644 --- a/registry/coder/modules/jupyter-notebook/main.test.ts +++ b/registry/coder/modules/jupyter-notebook/main.test.ts @@ -26,10 +26,11 @@ describe("jupyter-notebook", async () => { }); const script = findResourceInstance(state, "coder_script").script; - expect(script).toContain("--NotebookApp.ip='127.0.0.1'"); + expect(script).toContain("--ServerApp.ip='127.0.0.1'"); expect(script).not.toContain("0.0.0.0"); expect(script).not.toContain("--NotebookApp.ip='*'"); expect(script).not.toContain("--ServerApp.ip='*'"); + expect(script).toContain("--ServerApp.allow_remote_access=True"); const id = await runContainer("alpine"); try { @@ -54,7 +55,8 @@ describe("jupyter-notebook", async () => { ); expect(result.exitCode).toBe(0); const args = await readFileContainer(id, "/tmp/jupyter-args"); - expect(args).toContain("--NotebookApp.ip=127.0.0.1"); + expect(args).toContain("--ServerApp.ip=127.0.0.1"); + expect(args).toContain("--ServerApp.allow_remote_access=True"); } finally { await removeContainer(id); } @@ -66,7 +68,17 @@ describe("jupyter-notebook", async () => { host: "0.0.0.0", }); const script = findResourceInstance(state, "coder_script").script; - expect(script).toContain("--NotebookApp.ip='0.0.0.0'"); + expect(script).toContain("--ServerApp.ip='0.0.0.0'"); + expect(script).toContain("--ServerApp.allow_remote_access=True"); + }); + + it("renders an IPv6 loopback host", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + host: "::1", + }); + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain("--ServerApp.ip='::1'"); }); for (const unsafeHost of [ diff --git a/registry/coder/modules/jupyter-notebook/main.tf b/registry/coder/modules/jupyter-notebook/main.tf index d1843bd0d..b84b264aa 100644 --- a/registry/coder/modules/jupyter-notebook/main.tf +++ b/registry/coder/modules/jupyter-notebook/main.tf @@ -27,8 +27,8 @@ variable "host" { default = "127.0.0.1" validation { - condition = can(regex("^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", var.host)) - error_message = "host must contain only letters, numbers, dots, and hyphens, and must start and end with a letter or number." + condition = can(regex("^[A-Za-z0-9:][A-Za-z0-9.:%_-]*$", var.host)) + error_message = "host must contain only letters, numbers, colons, dots, percent signs, underscores, and hyphens." } } diff --git a/registry/coder/modules/jupyter-notebook/main.tftest.hcl b/registry/coder/modules/jupyter-notebook/main.tftest.hcl index ffb1a779f..8f683e889 100644 --- a/registry/coder/modules/jupyter-notebook/main.tftest.hcl +++ b/registry/coder/modules/jupyter-notebook/main.tftest.hcl @@ -11,10 +11,15 @@ run "secure_defaults" { } assert { - condition = strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='127.0.0.1'") + condition = strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='127.0.0.1'") error_message = "the default script must bind Jupyter Notebook to 127.0.0.1" } + assert { + condition = strcontains(coder_script.jupyter-notebook.script, "--ServerApp.allow_remote_access=True") + error_message = "the script must allow requests forwarded by the Coder application proxy" + } + assert { condition = !strcontains(coder_script.jupyter-notebook.script, "0.0.0.0") && !strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='*'") && !strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='*'") error_message = "the default script must not bind Jupyter Notebook to all interfaces" @@ -35,11 +40,25 @@ run "explicit_external_host" { } assert { - condition = strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='0.0.0.0'") + condition = strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='0.0.0.0'") error_message = "the script must render an explicitly configured host" } } +run "ipv6_loopback_host" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "::1" + } + + assert { + condition = strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='::1'") + error_message = "the script must render an IPv6 loopback host" + } +} + run "unsafe_host_rejected" { command = plan diff --git a/registry/coder/modules/jupyter-notebook/run.sh b/registry/coder/modules/jupyter-notebook/run.sh index 36559ec86..daf2abf73 100644 --- a/registry/coder/modules/jupyter-notebook/run.sh +++ b/registry/coder/modules/jupyter-notebook/run.sh @@ -41,8 +41,9 @@ fi echo "👷 Starting jupyter-notebook in background..." echo "check logs at ${LOG_PATH}" "$HOME/.local/bin/jupyter-notebook" \ - --NotebookApp.ip='${HOST}' \ + --ServerApp.ip='${HOST}' \ --ServerApp.port='${PORT}' \ + --ServerApp.allow_remote_access=True \ --no-browser \ --ServerApp.token='' \ --ServerApp.password='' \ diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts index 721cfafb0..0b121fe00 100644 --- a/registry/coder/modules/jupyterlab/main.test.ts +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -184,6 +184,7 @@ describe("jupyterlab", async () => { expect(script).toContain("--ServerApp.ip='127.0.0.1'"); expect(script).not.toContain("0.0.0.0"); expect(script).not.toContain("--ServerApp.ip='*'"); + expect(script).toContain("--ServerApp.allow_remote_access=True"); expect(app.url).toBe("http://localhost:19999"); const id = await runContainer("alpine"); @@ -198,6 +199,7 @@ describe("jupyterlab", async () => { expect(result.exitCode).toBe(0); const args = await readFileContainer(id, "/tmp/jupyter-args"); expect(args).toContain("--ServerApp.ip=127.0.0.1"); + expect(args).toContain("--ServerApp.allow_remote_access=True"); } finally { await removeContainer(id); } @@ -216,6 +218,7 @@ describe("jupyterlab", async () => { ).script; expect(script).toContain("--ServerApp.ip='0.0.0.0'"); + expect(script).toContain("--ServerApp.allow_remote_access=True"); expect(script).toContain("--ServerApp.base_url=/@"); expect(script).toContain("/apps/jupyterlab"); }); diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf index 6d190f831..e9fd2e00d 100644 --- a/registry/coder/modules/jupyterlab/main.tf +++ b/registry/coder/modules/jupyterlab/main.tf @@ -47,8 +47,8 @@ variable "host" { default = "127.0.0.1" validation { - condition = can(regex("^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", var.host)) - error_message = "host must contain only letters, numbers, dots, and hyphens, and must start and end with a letter or number." + condition = can(regex("^[A-Za-z0-9:][A-Za-z0-9.:%_-]*$", var.host)) + error_message = "host must contain only letters, numbers, colons, dots, percent signs, underscores, and hyphens." } } diff --git a/registry/coder/modules/jupyterlab/main.tftest.hcl b/registry/coder/modules/jupyterlab/main.tftest.hcl index 6fbc73647..eab375b04 100644 --- a/registry/coder/modules/jupyterlab/main.tftest.hcl +++ b/registry/coder/modules/jupyterlab/main.tftest.hcl @@ -15,6 +15,11 @@ run "secure_defaults" { error_message = "the default script must bind JupyterLab to 127.0.0.1" } + assert { + condition = strcontains(coder_script.jupyterlab.script, "--ServerApp.allow_remote_access=True") + error_message = "the script must allow requests forwarded by the Coder application proxy" + } + assert { condition = !strcontains(coder_script.jupyterlab.script, "0.0.0.0") && !strcontains(coder_script.jupyterlab.script, "--ServerApp.ip='*'") error_message = "the default script must not bind JupyterLab to all interfaces" @@ -70,6 +75,20 @@ run "path_mode_and_external_host" { } } +run "ipv6_loopback_host" { + command = plan + + variables { + agent_id = "test-agent-id" + host = "::1" + } + + assert { + condition = strcontains(coder_script.jupyterlab.script, "--ServerApp.ip='::1'") + error_message = "the script must render an IPv6 loopback host" + } +} + run "unsafe_host_rejected" { command = plan diff --git a/registry/coder/modules/jupyterlab/run.sh b/registry/coder/modules/jupyterlab/run.sh index 626a5dba1..dbc0d0174 100644 --- a/registry/coder/modules/jupyterlab/run.sh +++ b/registry/coder/modules/jupyterlab/run.sh @@ -53,6 +53,7 @@ printf "check logs at ${LOG_PATH}" "$BASE_URL_FLAG" \ --ServerApp.ip='${HOST}' \ --ServerApp.port='${PORT}' \ + --ServerApp.allow_remote_access=True \ --ServerApp.token='' \ --ServerApp.password='' \ > "${LOG_PATH}" 2>&1 & From 98d18563952af10d8e6e7abac9445c2403dd2386 Mon Sep 17 00:00:00 2001 From: eddymarc <132223353+Edd88-pixel@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:44:37 +0100 Subject: [PATCH 3/4] fix(jupyter): satisfy README validation --- registry/coder/modules/jupyter-notebook/README.md | 2 -- registry/coder/modules/jupyterlab/README.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/registry/coder/modules/jupyter-notebook/README.md b/registry/coder/modules/jupyter-notebook/README.md index 7de146d4d..1d2089d72 100644 --- a/registry/coder/modules/jupyter-notebook/README.md +++ b/registry/coder/modules/jupyter-notebook/README.md @@ -15,8 +15,6 @@ A module that adds Jupyter Notebook in your Coder template. Jupyter Notebook listens on `127.0.0.1` by default so that unauthenticated traffic must pass through Coder's application proxy. -## Usage - ```tf module "jupyter-notebook" { count = data.coder_workspace.me.start_count diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md index bf0f77e19..7e39df91a 100644 --- a/registry/coder/modules/jupyterlab/README.md +++ b/registry/coder/modules/jupyterlab/README.md @@ -15,8 +15,6 @@ A module that adds JupyterLab in your Coder template. JupyterLab listens on `127.0.0.1` by default so that unauthenticated traffic must pass through Coder's application proxy. -## Usage - ```tf module "jupyterlab" { count = data.coder_workspace.me.start_count From 77db73e163573c9f2c9f691450eb48593fc9be10 Mon Sep 17 00:00:00 2001 From: eddymarc <132223353+Edd88-pixel@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:01:52 +0100 Subject: [PATCH 4/4] test(jupyter-notebook): remove outdated NotebookApp assertions --- registry/coder/modules/jupyter-notebook/main.test.ts | 1 - registry/coder/modules/jupyter-notebook/main.tftest.hcl | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/registry/coder/modules/jupyter-notebook/main.test.ts b/registry/coder/modules/jupyter-notebook/main.test.ts index d0028cedc..2bcb3ea25 100644 --- a/registry/coder/modules/jupyter-notebook/main.test.ts +++ b/registry/coder/modules/jupyter-notebook/main.test.ts @@ -28,7 +28,6 @@ describe("jupyter-notebook", async () => { expect(script).toContain("--ServerApp.ip='127.0.0.1'"); expect(script).not.toContain("0.0.0.0"); - expect(script).not.toContain("--NotebookApp.ip='*'"); expect(script).not.toContain("--ServerApp.ip='*'"); expect(script).toContain("--ServerApp.allow_remote_access=True"); diff --git a/registry/coder/modules/jupyter-notebook/main.tftest.hcl b/registry/coder/modules/jupyter-notebook/main.tftest.hcl index 8f683e889..e6a351346 100644 --- a/registry/coder/modules/jupyter-notebook/main.tftest.hcl +++ b/registry/coder/modules/jupyter-notebook/main.tftest.hcl @@ -21,7 +21,7 @@ run "secure_defaults" { } assert { - condition = !strcontains(coder_script.jupyter-notebook.script, "0.0.0.0") && !strcontains(coder_script.jupyter-notebook.script, "--NotebookApp.ip='*'") && !strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='*'") + condition = !strcontains(coder_script.jupyter-notebook.script, "0.0.0.0") && !strcontains(coder_script.jupyter-notebook.script, "--ServerApp.ip='*'") error_message = "the default script must not bind Jupyter Notebook to all interfaces" }