diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 30700adf7..1f4fe24a0 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "4cf6c7f5a03b5a1d2d0046ed035ddc4db5158516" + "sharedInputsHash": "d2f7e8475cdf15e5334b349420182ca28dba8b22" } 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..834b1aed3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,6 +7,46 @@ 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 + # 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. + 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..f771e4e3d --- /dev/null +++ b/scripts/test-install-version-format.sh @@ -0,0 +1,73 @@ +#!/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" + +# 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"