From 16c32b8c7104951aedceaf29072840238fd40268 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:19:48 +0000 Subject: [PATCH 1/3] fix: reject malformed deployment domains --- deploy/ci/deployment-scripts.test.mjs | 50 +++++++++++++++++++++++++++ deploy/scripts/deploy.sh | 5 +-- deploy/scripts/lib.sh | 12 ++++++- package.json | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 deploy/ci/deployment-scripts.test.mjs diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs new file mode 100644 index 0000000000..2e749d3cd1 --- /dev/null +++ b/deploy/ci/deployment-scripts.test.mjs @@ -0,0 +1,50 @@ +import { spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolve } from 'node:path'; + +const validationScript = resolve(import.meta.dirname, '../scripts/lib.sh'); + +function validateDomain(domain) { + return spawnSync( + 'bash', + [ + '-c', + 'source "$1"; validate_domain "$2"', + 'validate-domain', + validationScript, + domain, + ], + { encoding: 'utf8' }, + ); +} + +test('deployment domains use valid DNS labels', () => { + const maximumLengthDomain = `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`; + + for (const domain of [ + 'roomote.example.com', + 'a', + 'a.example', + `${'a'.repeat(63)}.example`, + maximumLengthDomain, + ]) { + const result = validateDomain(domain); + assert.equal(result.status, 0, `${domain}: ${result.stderr}`); + } + + for (const domain of [ + '', + '.example.com', + 'example.com.', + 'foo..example.com', + '-foo.example.com', + 'foo-.example.com', + `${'a'.repeat(64)}.example`, + `${maximumLengthDomain}e`, + ]) { + const result = validateDomain(domain); + assert.equal(result.status, 1, `${domain} was accepted`); + assert.match(result.stderr, /error: invalid domain:/); + } +}); diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index eb7c73ee94..d02adbe9cd 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -190,8 +190,9 @@ if [ "$database_mode" = "external" ] && ! env_has_key "$env_file" DATABASE_URL; die "--database external requires DATABASE_URL in $env_file" fi -if [ "$manage_dns" = "true" ] && [ -z "$dns_zone" ]; then - die "--dns-zone is required with --manage-dns" +if [ "$manage_dns" = "true" ]; then + [ -n "$dns_zone" ] || die "--dns-zone is required with --manage-dns" + validate_domain "$dns_zone" fi if [ "${#ssh_allowed_cidrs[@]}" -eq 0 ]; then diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh index a2007be526..78fb4c4a06 100644 --- a/deploy/scripts/lib.sh +++ b/deploy/scripts/lib.sh @@ -43,7 +43,17 @@ validate_slug() { } validate_domain() { - [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$ ]] || die "invalid domain: $1" + local domain="$1" + local label + local -a labels + + if [ -z "$domain" ] || [ "${#domain}" -gt 253 ] || [[ "$domain" = .* || "$domain" = *. ]]; then + die "invalid domain: $domain" + fi + IFS='.' read -r -a labels <<<"$domain" + for label in "${labels[@]}"; do + [[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || die "invalid domain: $domain" + done } validate_image_part() { diff --git a/package.json b/package.json index cb0d333a5b..7823ec2e3a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "db:reset": "pnpm db:down && docker volume rm -f roomote_pg_data roomote_redis_data roomote_minio_data && pnpm db:up", "db:seed": "pnpm --silent --filter @roomote/db db:seed", "db:seed:demo": "pnpm --silent --filter @roomote/db db:seed:demo", - "deployment:validate": "node deploy/ci/validate-deployment-artifacts.mjs", + "deployment:validate": "node --test deploy/ci/deployment-scripts.test.mjs && node deploy/ci/validate-deployment-artifacts.mjs", "deployment:smoke": "bash deploy/ci/deployment-smoke.sh", "dev": "pnpm --silent --filter @roomote/dev dev", "doctor": "pnpm --filter @roomote/dev run doctor", From 1ad8d208983536b65df337cdf7fdb9920c3e3a17 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:03:36 +0000 Subject: [PATCH 2/3] fix: reject newline-embedded domains and avoid shell string in domain test - Reject embedded newlines/carriage returns in validate_domain before label splitting, since 'read' only consumes the first line of a here-string and previously let unvalidated trailing content through. - Add a regression case covering a domain with an embedded newline. - Restructure the deployment-scripts test to invoke bash with the runner script and candidate domain as explicit positional arguments instead of interpolating the script path into a '-c' shell string, clearing the CodeQL 'shell command built from environment values' finding while keeping the subprocess-boundary coverage intact. --- deploy/ci/deployment-scripts.test.mjs | 21 +++++++++------------ deploy/ci/validate-domain-subprocess.sh | 13 +++++++++++++ deploy/scripts/lib.sh | 3 +++ 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100755 deploy/ci/validate-domain-subprocess.sh diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs index 2e749d3cd1..7ec0e96d0c 100644 --- a/deploy/ci/deployment-scripts.test.mjs +++ b/deploy/ci/deployment-scripts.test.mjs @@ -3,20 +3,16 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { resolve } from 'node:path'; -const validationScript = resolve(import.meta.dirname, '../scripts/lib.sh'); +const validationRunner = resolve( + import.meta.dirname, + 'validate-domain-subprocess.sh', +); function validateDomain(domain) { - return spawnSync( - 'bash', - [ - '-c', - 'source "$1"; validate_domain "$2"', - 'validate-domain', - validationScript, - domain, - ], - { encoding: 'utf8' }, - ); + // Pass the runner script and the candidate domain as explicit positional + // arguments to `bash` rather than interpolating either into a `-c` shell + // string, so no value here is parsed as shell command text. + return spawnSync('bash', [validationRunner, domain], { encoding: 'utf8' }); } test('deployment domains use valid DNS labels', () => { @@ -42,6 +38,7 @@ test('deployment domains use valid DNS labels', () => { 'foo-.example.com', `${'a'.repeat(64)}.example`, `${maximumLengthDomain}e`, + 'roomote.example.com\nnot-a-domain', ]) { const result = validateDomain(domain); assert.equal(result.status, 1, `${domain} was accepted`); diff --git a/deploy/ci/validate-domain-subprocess.sh b/deploy/ci/validate-domain-subprocess.sh new file mode 100755 index 0000000000..b8a986438c --- /dev/null +++ b/deploy/ci/validate-domain-subprocess.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Test-only harness: sources the deployment lib and validates the domain +# passed as $1. Invoked as an explicit positional argument to `bash` (no +# `-c` shell string), so the caller never interpolates a path into shell +# command text. + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/../scripts/lib.sh" + +validate_domain "$1" diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh index 78fb4c4a06..53bde22b97 100644 --- a/deploy/scripts/lib.sh +++ b/deploy/scripts/lib.sh @@ -50,6 +50,9 @@ validate_domain() { if [ -z "$domain" ] || [ "${#domain}" -gt 253 ] || [[ "$domain" = .* || "$domain" = *. ]]; then die "invalid domain: $domain" fi + case "$domain" in + *$'\n'* | *$'\r'*) die "invalid domain: $domain" ;; + esac IFS='.' read -r -a labels <<<"$domain" for label in "${labels[@]}"; do [[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || die "invalid domain: $domain" From fea44ee0d6659944b9e6beae4bac077155038098 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:30:17 +0000 Subject: [PATCH 3/3] test: assert carriage-return domains are rejected --- deploy/ci/deployment-scripts.test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs index 7ec0e96d0c..e93b8d44d2 100644 --- a/deploy/ci/deployment-scripts.test.mjs +++ b/deploy/ci/deployment-scripts.test.mjs @@ -39,6 +39,9 @@ test('deployment domains use valid DNS labels', () => { `${'a'.repeat(64)}.example`, `${maximumLengthDomain}e`, 'roomote.example.com\nnot-a-domain', + 'roomote.example.com\rnot-a-domain', + 'roomote.example.com\r', + 'roomote.example.com\r\nnot-a-domain', ]) { const result = validateDomain(domain); assert.equal(result.status, 1, `${domain} was accepted`);