From db52756c4de16ce5eaa5b3a7635e66f1b30ff5a5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 14:06:31 +0900 Subject: [PATCH 1/3] fix(install): Validate ULOOP_VERSION shape before use in install.sh/install.ps1 Both installer scripts interpolate ULOOP_VERSION into the release download URL without normalizing dot segments, so a value like "../../evil/repo/releases/download/v1" would break out of the intended release path. Fail-close on any value that is not one of the two well-known channel selectors or a semver-shaped tag (bare, dispatcher-v-prefixed, uloop-project-runner-v-prefixed, or v-prefixed) so downstream URL builders and ULOOP_VERSION passthrough only ever see a value they were designed for. Existing dispatcher self-update flows already emit values that pass this check via NormalizeTargetVersion + DispatcherReleaseTag; the guard is defensive coverage for the ambient-env attacker. --- cli/dispatcher/shared-inputs-stamp.json | 2 +- scripts/install.ps1 | 23 +++++++++ scripts/install.sh | 28 +++++++++++ scripts/test-install-version-format.sh | 67 +++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100755 scripts/test-install-version-format.sh diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 30700adf7..ae6307d18 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "4cf6c7f5a03b5a1d2d0046ed035ddc4db5158516" + "sharedInputsHash": "9a817d74fe6be397a79f89a330b444c36d531535" } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d4cccf763..ba7d24303 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -11,6 +11,29 @@ $InstallDir = if ($env:ULOOP_INSTALL_DIR) { } $AssetName = "uloop-dispatcher-windows-amd64.zip" +function Test-UloopVersionFormat { + param( + [Parameter(Mandatory = $true)] + [string]$Candidate + ) + + # Why: mirror install.sh — Uri.Combine and .NET's WebClient do not + # normalize away path segments, so a value like + # "../../evil/repo/releases/download/v1" would traverse out of the + # expected release path. Fail-close on anything that is not one of the + # two well-known channel selectors or a semver-shaped tag. + if ($Candidate -eq $LatestVersion -or $Candidate -eq $LatestBetaVersion) { + return + } + $Pattern = '^(dispatcher-v|uloop-project-runner-v|v)?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + if ($Candidate -match $Pattern) { + return + } + throw "Invalid ULOOP_VERSION: $Candidate. Expected 'latest', 'latest-beta', or a semver tag such as '3.0.0-beta.5' / 'dispatcher-v3.0.0-beta.5'." +} + +Test-UloopVersionFormat -Candidate $Version + function Find-LatestAssetUrl { param( [Parameter(Mandatory = $true)] diff --git a/scripts/install.sh b/scripts/install.sh index 8b8a46338..6a1c3bdcb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,6 +7,34 @@ VERSION="${ULOOP_VERSION:-latest}" LATEST_VERSION="latest" LATEST_BETA_VERSION="latest-beta" +# Why: install.sh interpolates $VERSION into +# "https://github.com/$REPOSITORY/releases/download/$VERSION/..." without +# curl normalizing dot segments, so a value like +# "../../evil/repo/releases/download/v1" would break out of the intended +# release path even though the ambient attacker who can already set env +# usually has bigger wins. Fail-close early on any value that is neither +# the two well-known channel selectors nor a semantic-version-shaped tag, +# so downstream users of $VERSION (asset URL, ULOOP_VERSION passthrough, +# release tag prefixing) never see a value they weren't designed for. +validate_uloop_version() { + candidate=$1 + if [ "$candidate" = "$LATEST_VERSION" ] || [ "$candidate" = "$LATEST_BETA_VERSION" ]; then + return + fi + # POSIX ERE: optional dispatcher-v / uloop-project-runner-v / v prefix, + # then MAJOR.MINOR.PATCH, then optional -prerelease.identifiers or + # +build.metadata. Allow only [0-9A-Za-z-.] in the tail so path traversal + # (`/`, `..`) and query-string escapes (`?`, `#`, `%`) cannot survive. + if printf '%s' "$candidate" | grep -Eq '^(dispatcher-v|uloop-project-runner-v|v)?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + return + fi + echo "Invalid ULOOP_VERSION: $candidate" >&2 + echo "Expected 'latest', 'latest-beta', or a semver tag such as '3.0.0-beta.5' / 'dispatcher-v3.0.0-beta.5'." >&2 + exit 1 +} + +validate_uloop_version "$VERSION" + report_path_shadowing() { resolved_uloop=$(command -v uloop 2>/dev/null || true) expected_uloop="$INSTALL_DIR/$installed_command_name" diff --git a/scripts/test-install-version-format.sh b/scripts/test-install-version-format.sh new file mode 100755 index 000000000..fd626a15d --- /dev/null +++ b/scripts/test-install-version-format.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Verifies install.sh's validate_uloop_version helper accepts every version +# shape the dispatcher self-update flow can emit (latest, latest-beta, bare +# semver, dispatcher-v prefix, project-runner prefix, prerelease + build +# metadata) and rejects values that could break out of the release path. +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +INSTALL_SCRIPT="$ROOT_DIR/scripts/install.sh" + +extract_function() { + name=$1 + awk -v want="$name" ' + $0 ~ ("^" want "\\(\\)") { in_fn = 1 } + in_fn { print } + in_fn && $0 == "}" { exit } + ' "$INSTALL_SCRIPT" +} + +LATEST_VERSION="latest" +LATEST_BETA_VERSION="latest-beta" +eval "$(extract_function validate_uloop_version)" + +expect_pass() { + value=$1 + if ! (validate_uloop_version "$value") >/dev/null 2>&1; then + echo "FAIL: expected pass for '$value'" >&2 + exit 1 + fi +} + +expect_fail() { + value=$1 + if (validate_uloop_version "$value") >/dev/null 2>&1; then + echo "FAIL: expected reject for '$value'" >&2 + exit 1 + fi +} + +# Well-known channel selectors — must always pass. +expect_pass "latest" +expect_pass "latest-beta" + +# Bare semver as emitted by --to-version after NormalizeTargetVersion. +expect_pass "3.0.0" +expect_pass "3.0.0-beta.5" +expect_pass "3.0.1-beta.12" +expect_pass "1.2.3-rc.1+build.7" + +# Release-tag prefixed values as emitted by DispatcherReleaseTag / +# ProjectRunnerReleaseTag / bare 'v' prefix. +expect_pass "dispatcher-v3.0.0" +expect_pass "dispatcher-v3.0.0-beta.5" +expect_pass "uloop-project-runner-v3.0.0-beta.43" +expect_pass "v3.0.0" + +# Path traversal or URL-injection shapes must not survive. +expect_fail "../../evil/repo/releases/download/v1" +expect_fail "3.0.0/../evil" +expect_fail "3.0.0?redirect=evil" +expect_fail "3.0.0#fragment" +expect_fail "3.0.0 3.0.0" +expect_fail "" +expect_fail "not-a-version" +expect_fail "3.0" + +echo "OK" From 900663a4678aa6916271977b1f9bc35aed9b73d4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 16:08:12 +0900 Subject: [PATCH 2/3] fix(install): Reject ULOOP_VERSION values with embedded newlines grep -Eq matches per line, so a value like `printf '../evil\n3.0.0'` was passing the ERE on its second line while smuggling a newline-poisoned first line into the URL builder downstream. Add a whole-string `case` guard before the grep so any character outside the semver tag alphabet (digits, letters, `.`, `+`, `-`) is rejected at the string level. install.ps1 already anchors ^...$ at string level so this bypass never existed there. Add two regression cases with embedded newlines to test-install-version-format.sh so the whole-string guard cannot regress. --- scripts/install.sh | 16 ++++++++++++++-- scripts/test-install-version-format.sh | 6 ++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 6a1c3bdcb..834b1aed3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -21,10 +21,22 @@ validate_uloop_version() { if [ "$candidate" = "$LATEST_VERSION" ] || [ "$candidate" = "$LATEST_BETA_VERSION" ]; then return fi + # Why: `grep -Eq` matches per line, so a value like + # printf '../evil\n3.0.0' would pass the ERE below on its second line + # while leaking the embedded newline into the URL builder downstream. + # Reject anything outside the semver tag alphabet at the whole-string + # level first — `case` sees the value as one string and cannot be + # smuggled past with embedded newlines or NULs. + case $candidate in + ''|*[!0-9A-Za-z.+-]*) + echo "Invalid ULOOP_VERSION: $candidate" >&2 + echo "Expected 'latest', 'latest-beta', or a semver tag such as '3.0.0-beta.5' / 'dispatcher-v3.0.0-beta.5'." >&2 + exit 1 + ;; + esac # POSIX ERE: optional dispatcher-v / uloop-project-runner-v / v prefix, # then MAJOR.MINOR.PATCH, then optional -prerelease.identifiers or - # +build.metadata. Allow only [0-9A-Za-z-.] in the tail so path traversal - # (`/`, `..`) and query-string escapes (`?`, `#`, `%`) cannot survive. + # +build.metadata. if printf '%s' "$candidate" | grep -Eq '^(dispatcher-v|uloop-project-runner-v|v)?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then return fi diff --git a/scripts/test-install-version-format.sh b/scripts/test-install-version-format.sh index fd626a15d..f771e4e3d 100755 --- a/scripts/test-install-version-format.sh +++ b/scripts/test-install-version-format.sh @@ -64,4 +64,10 @@ expect_fail "" expect_fail "not-a-version" expect_fail "3.0" +# Embedded newline: grep -Eq alone matches per-line, so "evil\n3.0.0" would +# smuggle a URL-poisoning first line past the ERE. The whole-string `case` +# guard in front of the grep must catch this. +expect_fail "$(printf 'evil\n3.0.0')" +expect_fail "$(printf '3.0.0\n../evil')" + echo "OK" From 7bfaaa9e7e1b16d01f602b4622147353ab7dd026 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 16:33:04 +0900 Subject: [PATCH 3/3] chore: Refresh dispatcher shared-inputs stamp for install.sh newline guard commit 900663a4 re-edited scripts/install.sh (added the case-based whole-string guard) without re-running scripts/stamp-release-inputs.sh, leaving the stamp's sharedInputsHash stale for the current install.sh contents. Restamp so the file matches the input set. --- cli/dispatcher/shared-inputs-stamp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index ae6307d18..1f4fe24a0 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "9a817d74fe6be397a79f89a330b444c36d531535" + "sharedInputsHash": "d2f7e8475cdf15e5334b349420182ca28dba8b22" }