From cf836715e276b18cfe7facf19e5e1e8fdee0d404 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Sat, 5 Sep 2026 18:11:58 +0800 Subject: [PATCH] fix: prevent loopback Run release URLs --- .env.example | 2 + docker-compose.yml | 1 + platform/.env.example | 4 +- platform/README.md | 2 +- .../service/distribution_build_execution.go | 6 +- platform/service/distribution_build_jobs.go | 6 +- platform/service/distributions.go | 30 ++++++- platform/service/distributions_test.go | 88 +++++++++++++++++++ scripts/local-debug/env.sh | 2 +- scripts/local-debug/smoke.sh | 4 +- 10 files changed, 133 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3f79040..c28e67f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ # Copy to .env if you want docker compose to read custom values automatically. PLATFORM_ADDR=:8080 +# Public Platform URL compiled into generated Run packages. It must be reachable from the target server. +PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com/ # Platform metadata storage backend: # - file: default, writes platform metadata JSON to PLATFORM_METADATA_PATH. diff --git a/docker-compose.yml b/docker-compose.yml index a4dbd57..db5ffdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: PLATFORM_LOG_DIR: /data/platform/logs PLATFORM_ARTIFACT_DIR: /data/platform/artifacts PLATFORM_SECRET_ENVELOPE_KEY: ${PLATFORM_SECRET_ENVELOPE_KEY:-local-compose-secret-envelope-key-change-me} + PLATFORM_RUN_RELEASE_URL: ${PLATFORM_RUN_RELEASE_URL:-https://scum.npc0.com/} ports: - "8080:8080" volumes: diff --git a/platform/.env.example b/platform/.env.example index 43009e1..9842bae 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -37,5 +37,5 @@ PLATFORM_BUILDER_SOURCE_REPOSITORY=git@git.npc0.com:admin343/run.git PLATFORM_BUILDER_SOURCE_REVISION=main PLATFORM_BUILDER_WORKSPACE_DIR=.platform-data/distribution-builds PLATFORM_BUILDER_TIMEOUT_SECONDS=1800 -# URL embedded into generated Run packages; override for tunnel or production access. -PLATFORM_RUN_RELEASE_URL=http://127.0.0.1:8080 +# URL embedded into generated Run packages. It must be reachable from the target server. +PLATFORM_RUN_RELEASE_URL=https://scum.npc0.com/ diff --git a/platform/README.md b/platform/README.md index daa658a..e0f155b 100644 --- a/platform/README.md +++ b/platform/README.md @@ -62,7 +62,7 @@ Runtime configuration: - `PLATFORM_BUILDER_WORKSPACE_DIR`: private per-plugin/per-job build workspace, default `/distribution-builds`. - `PLATFORM_BUILDER_CACHE_DIR`: persistent Go build/module cache, default `/distribution-build-cache`; it contains no job inputs or component keys. - `PLATFORM_BUILDER_TIMEOUT_SECONDS`: positive build deadline, default `1800`. -- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated Run packages, default `http://127.0.0.1:8080/`; set this explicitly for tunnel or production access. +- `PLATFORM_RUN_RELEASE_URL`: public platform URL embedded into generated Run packages, default `https://scum.npc0.com/`. It must be reachable from the target server; loopback and unspecified addresses are rejected. Build the dedicated toolchain image before enabling distribution generation: diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index ccd741d..0db1025 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -140,6 +140,10 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error { // component auth key, inside the platform. Unlike GetDistributionBuildInput it // never crosses the job channel. func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.DistributionBuildInput, error) { + platformURL, err := runReleasePlatformURL() + if err != nil { + return domain.DistributionBuildInput{}, err + } runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { return domain.DistributionBuildInput{}, err @@ -173,7 +177,7 @@ func (svc *CoreService) platformDistributionBuildInput(job domain.Job) (domain.D TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, - PlatformURL: runReleasePlatformURL(), + PlatformURL: platformURL, PackageFormat: distribution.PackageFormat, ArtifactID: distribution.ArtifactID, OutputFilename: executableFilename("run", distribution.TargetOS), diff --git a/platform/service/distribution_build_jobs.go b/platform/service/distribution_build_jobs.go index 70c4e8c..07d7775 100644 --- a/platform/service/distribution_build_jobs.go +++ b/platform/service/distribution_build_jobs.go @@ -30,6 +30,10 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning { return domain.DistributionBuildInput{}, validationError("distribution build job is not active") } + platformURL, err := runReleasePlatformURL() + if err != nil { + return domain.DistributionBuildInput{}, err + } runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID}) if err != nil { @@ -64,7 +68,7 @@ func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBui TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, TargetRelease: distribution.ID, - PlatformURL: runReleasePlatformURL(), + PlatformURL: platformURL, PackageFormat: distribution.PackageFormat, ArtifactID: distribution.ArtifactID, OutputFilename: executableFilename("run", distribution.TargetOS), diff --git a/platform/service/distributions.go b/platform/service/distributions.go index 59566de..b9525cb 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "errors" "fmt" + "net" + "net/url" "os" "strings" @@ -48,6 +50,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ if ready, reason := svc.distributionBuilderReadiness(); !ready { return domain.RunDistribution{}, validationError(reason) } + if _, err := runReleasePlatformURL(); err != nil { + return domain.RunDistribution{}, err + } key, _, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") if err != nil { @@ -778,11 +783,28 @@ func packageFormatForTarget(targetOS string) string { return "tar.gz" } -func runReleasePlatformURL() string { - if value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")); value != "" { - return value +const defaultRunReleasePlatformURL = "https://scum.npc0.com/" + +func runReleasePlatformURL() (string, error) { + value := strings.TrimSpace(os.Getenv("PLATFORM_RUN_RELEASE_URL")) + if value == "" { + value = defaultRunReleasePlatformURL } - return "http://127.0.0.1:8080/" + parsed, err := url.ParseRequestURI(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(value, "#") { + return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL without credentials, query parameters, or fragments") + } + host := strings.TrimRight(strings.ToLower(parsed.Hostname()), ".") + if host == "" { + return "", validationError("PLATFORM_RUN_RELEASE_URL must be an absolute http or https URL with a host") + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages") + } + if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) { + return "", validationError("PLATFORM_RUN_RELEASE_URL must use an address reachable from the target server; loopback or unspecified addresses are not valid for generated Run packages") + } + return value, nil } func distributionID(prefix string, parts ...interface{}) string { diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 2c2625d..78ef2a5 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -23,6 +23,94 @@ type generatedPackageConfig struct { AuthKey string } +func TestRunReleasePlatformURLUsesExternalDefault(t *testing.T) { + t.Setenv("PLATFORM_RUN_RELEASE_URL", "") + + value, err := runReleasePlatformURL() + if err != nil { + t.Fatalf("resolve default Run release URL: %v", err) + } + if value != defaultRunReleasePlatformURL { + t.Fatalf("unexpected default Run release URL %q", value) + } +} + +func TestRunReleasePlatformURLRejectsTargetLocalAddresses(t *testing.T) { + for _, value := range []string{ + "http://127.0.0.1:18080", + "http://127.0.0.2:18080", + "http://localhost:18080", + "https://platform.localhost/", + "http://[::1]:18080", + "http://0.0.0.0:18080", + "http://[::]:18080", + } { + t.Run(value, func(t *testing.T) { + t.Setenv("PLATFORM_RUN_RELEASE_URL", value) + if _, err := runReleasePlatformURL(); err == nil || !strings.Contains(err.Error(), "reachable from the target server") { + t.Fatalf("expected target-local URL %q to be rejected, got %v", value, err) + } + }) + } +} + +func TestRunReleasePlatformURLAcceptsExternalHTTPURL(t *testing.T) { + t.Setenv("PLATFORM_RUN_RELEASE_URL", "http://platform.example.test:8080/control") + + value, err := runReleasePlatformURL() + if err != nil { + t.Fatalf("resolve external Run release URL: %v", err) + } + if value != "http://platform.example.test:8080/control" { + t.Fatalf("unexpected external Run release URL %q", value) + } +} + +func TestRunReleasePlatformURLRejectsInvalidURLForms(t *testing.T) { + for _, value := range []string{ + "ftp://platform.example.test", + "https://operator@platform.example.test", + "https://platform.example.test/?token=value", + "https://platform.example.test/?", + "https://platform.example.test/#fragment", + "https://platform.example.test/#", + } { + t.Run(value, func(t *testing.T) { + t.Setenv("PLATFORM_RUN_RELEASE_URL", value) + if _, err := runReleasePlatformURL(); err == nil || !strings.Contains(err.Error(), "absolute http or https URL") { + t.Fatalf("expected invalid Run release URL %q to be rejected, got %v", value, err) + } + }) + } +} + +func TestCoreServiceRejectsLoopbackRunReleaseURLBeforeGeneration(t *testing.T) { + t.Setenv("PLATFORM_RUN_RELEASE_URL", "http://127.0.0.1:18080") + svc, session, instance := newDistributionTestFixture(t) + + _, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ + ServerInstanceID: instance.ID, + TargetOS: "windows", + TargetArch: "amd64", + IdempotencyKey: "loopback-release-url", + }) + if err == nil || !strings.Contains(err.Error(), "reachable from the target server") { + t.Fatalf("expected loopback Run release URL to fail generation, got %v", err) + } + distributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: instance.ID}) + if err != nil || len(distributions) != 0 { + t.Fatalf("invalid release URL must not create distributions, distributions=%+v err=%v", distributions, err) + } + keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{ServerInstanceID: instance.ID, ComponentKind: domain.DistributionComponentRun}) + if err != nil || len(keys) != 0 { + t.Fatalf("invalid release URL must not create component keys, keys=%+v err=%v", keys, err) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) + if err != nil || len(jobs) != 0 { + t.Fatalf("invalid release URL must not create build jobs, jobs=%+v err=%v", jobs, err) + } +} + func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) diff --git a/scripts/local-debug/env.sh b/scripts/local-debug/env.sh index 26b1acd..a6d955f 100755 --- a/scripts/local-debug/env.sh +++ b/scripts/local-debug/env.sh @@ -25,7 +25,7 @@ export PLATFORM_BOOTSTRAP_ADMIN_EMAIL="${PLATFORM_BOOTSTRAP_ADMIN_EMAIL:-operato export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-operator-local}" export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}" export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}" -export PLATFORM_RUN_RELEASE_URL="${PLATFORM_RUN_RELEASE_URL:-http://127.0.0.1:$LOCAL_DEBUG_PLATFORM_PORT}" +export PLATFORM_RUN_RELEASE_URL="${PLATFORM_RUN_RELEASE_URL:-https://scum.npc0.com/}" export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}" export RUN_REPO_DIR="$RUN_SOURCE_DIR" diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index c82c4fe..691e55b 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -1571,8 +1571,8 @@ if [[ "$VITE_PLATFORM_API_BASE_URL" != "/api/v1" ]]; then printf 'VITE_PLATFORM_API_BASE_URL must be /api/v1, got %s\n' "$VITE_PLATFORM_API_BASE_URL" >&2 exit 1 fi -if [[ "$PLATFORM_RUN_RELEASE_URL" != "$PLATFORM_URL" ]]; then - printf 'PLATFORM_RUN_RELEASE_URL must be %s for executable local Run proof, got %s\n' "$PLATFORM_URL" "$PLATFORM_RUN_RELEASE_URL" >&2 +if [[ "$PLATFORM_RUN_RELEASE_URL" == "$PLATFORM_URL" ]]; then + printf 'PLATFORM_RUN_RELEASE_URL must use an address reachable from the generated Run target, not the local platform URL %s\n' "$PLATFORM_URL" >&2 exit 1 fi if [[ "$PLATFORM_API_PROXY" != "$PLATFORM_URL" ]]; then