diff --git a/frontend-integration/types/megaport-wasm.d.ts b/frontend-integration/types/megaport-wasm.d.ts index 89041d63..7202c28a 100644 --- a/frontend-integration/types/megaport-wasm.d.ts +++ b/frontend-integration/types/megaport-wasm.d.ts @@ -137,14 +137,14 @@ export interface MegaportWASM { * Does NOT use localStorage to prevent XSS attacks * @param accessKey - Megaport API access key * @param secretKey - Megaport API secret key - * @param environment - Environment (production, staging, development) - * @returns Result object with success status + * @param environment - Environment (production, staging, development; case- and whitespace-insensitive). Anything else is rejected rather than defaulting to production + * @returns On success: `{ success: true, environment }` where `environment` is the normalized bucket. On failure: `{ success: false, error }` */ setAuthCredentials( accessKey: string, secretKey: string, environment: string - ): { success: boolean; error?: string }; + ): { success: boolean; error?: string; environment?: string }; /** * Set authentication using an existing token from the portal session, @@ -333,7 +333,7 @@ declare global { accessKey: string, secretKey: string, environment: string - ) => { success: boolean; error?: string }; + ) => { success: boolean; error?: string; environment?: string }; setAuthToken?: ( token: string, hostname: string, diff --git a/internal/commands/config/login_wasm.go b/internal/commands/config/login_wasm.go index b52c682a..fb67f24c 100644 --- a/internal/commands/config/login_wasm.go +++ b/internal/commands/config/login_wasm.go @@ -140,7 +140,11 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { case "development": envOpt = megaport.WithEnvironment(megaport.EnvironmentDevelopment) default: - envOpt = megaport.WithEnvironment(megaport.EnvironmentProduction) + // Fail closed: an unrecognized or empty environment must not + // silently route a bearer token to production. + js.Global().Get("console").Call("error", "Unknown environment: "+tokenEnv) + js.Global().Get("console").Call("groupEnd") + return nil, fmt.Errorf(`unknown environment %q: expected "production", "staging", or "development"`, tokenEnv) } clientOpts = append(clientOpts, envOpt) } @@ -176,14 +180,18 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { js.Global().Get("console").Call("log", "Using secret key from environment variable") } - // Allow the megaportCredentials JS global's environment field to override - // the env var (the UI may set it before calling setAuthCredentials). - megaportCredsGlobal := js.Global().Get("megaportCredentials") - if !megaportCredsGlobal.IsUndefined() && !megaportCredsGlobal.IsNull() { - envVal := megaportCredsGlobal.Get("environment") - if envVal.Type() == js.TypeString { - if s := envVal.String(); s != "" { - env = s + // Only consult the page-writable megaportCredentials global when the + // trusted MEGAPORT_ENVIRONMENT bucket (set and validated by + // setAuthCredentials) is unset; never let the global override an + // already-validated environment. Mirrors the token path's precedence above. + if env == "" { + megaportCredsGlobal := js.Global().Get("megaportCredentials") + if !megaportCredsGlobal.IsUndefined() && !megaportCredsGlobal.IsNull() { + envVal := megaportCredsGlobal.Get("environment") + if envVal.Type() == js.TypeString { + if s := envVal.String(); s != "" { + env = s + } } } } @@ -206,7 +214,11 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { return nil, fmt.Errorf("megaport API secret key not provided. Please use the login form in the browser UI or set MEGAPORT_SECRET_KEY environment variable") } - // Default to production + // Default to production when no environment was specified at all. This is + // safe today because setAuthCredentials always sets MEGAPORT_ENVIRONMENT + // alongside the access key, so env is only ever "" here if accessKey was + // set some other way. An unrecognized (non-empty) value is handled by the + // switch's default case below, which fails closed rather than defaulting. if env == "" { env = "production" js.Global().Get("console").Call("log", "No environment specified, defaulting to production") @@ -227,8 +239,11 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { apiEndpoint = "https://api-mpone-dev.megaport.com" envOpt = megaport.WithEnvironment(megaport.EnvironmentDevelopment) default: - apiEndpoint = "https://api.megaport.com" - envOpt = megaport.WithEnvironment(megaport.EnvironmentProduction) + // Fail closed: an unrecognized environment must not silently route + // credentials to production. + js.Global().Get("console").Call("error", "Unknown environment: "+env) + js.Global().Get("console").Call("groupEnd") + return nil, fmt.Errorf(`unknown environment %q: expected "production", "staging", or "development"`, env) } js.Global().Get("console").Call("log", "Using API endpoint: "+apiEndpoint) diff --git a/internal/commands/config/login_wasm_test.go b/internal/commands/config/login_wasm_test.go index b8da7b1b..90b77299 100644 --- a/internal/commands/config/login_wasm_test.go +++ b/internal/commands/config/login_wasm_test.go @@ -5,6 +5,7 @@ package config import ( "context" "net/http" + "os" "syscall/js" "testing" "time" @@ -14,6 +15,91 @@ import ( "github.com/stretchr/testify/require" ) +// TestLoginFunc_UnknownEnvironmentFailsClosed verifies that the credential +// login path rejects an unrecognized MEGAPORT_ENVIRONMENT instead of +// silently coercing it to production, since accessKey/secretKey callers +// only reach this switch via setAuthCredentials, which is expected to have +// already bucketed the value into production/staging/development. +func TestLoginFunc_UnknownEnvironmentFailsClosed(t *testing.T) { + origAccessKey := os.Getenv("MEGAPORT_ACCESS_KEY") + origSecretKey := os.Getenv("MEGAPORT_SECRET_KEY") + origEnv := os.Getenv("MEGAPORT_ENVIRONMENT") + defer func() { + os.Setenv("MEGAPORT_ACCESS_KEY", origAccessKey) + os.Setenv("MEGAPORT_SECRET_KEY", origSecretKey) + os.Setenv("MEGAPORT_ENVIRONMENT", origEnv) + }() + + js.Global().Delete("megaportToken") + js.Global().Delete("megaportCredentials") + + os.Setenv("MEGAPORT_ACCESS_KEY", "test-access-key") + os.Setenv("MEGAPORT_SECRET_KEY", "test-secret-key") + os.Setenv("MEGAPORT_ENVIRONMENT", "not-a-real-environment") + + client, err := loginFunc(context.Background()) + + assert.Nil(t, client, "no client should be created for an unrecognized environment") + assert.ErrorContains(t, err, "unknown environment") + assert.ErrorContains(t, err, "not-a-real-environment") +} + +// TestLoginFunc_TokenPathUnknownEnvironmentFailsClosed verifies the bearer-token +// login path also fails closed on an unrecognized environment instead of silently +// routing the token to production, matching the credential path's behavior. +func TestLoginFunc_TokenPathUnknownEnvironmentFailsClosed(t *testing.T) { + t.Setenv("MEGAPORT_ACCESS_TOKEN", "test-token-12345") + t.Setenv("MEGAPORT_API_URL", "") + t.Setenv("MEGAPORT_ENVIRONMENT", "not-a-real-environment") + + setTamperedTokenGlobal("not-a-real-environment", "") + defer js.Global().Delete("megaportToken") + + client, err := loginFunc(context.Background()) + + assert.Nil(t, client, "no client should be created for an unrecognized token environment") + assert.ErrorContains(t, err, "unknown environment") + assert.ErrorContains(t, err, "not-a-real-environment") +} + +// TestLoginFunc_CredentialGlobalDoesNotOverrideValidatedEnv verifies the +// credential path ignores the page-writable megaportCredentials global when the +// trusted MEGAPORT_ENVIRONMENT bucket is already set, so a script that can write +// the global cannot redirect validated credentials to a different environment. +func TestLoginFunc_CredentialGlobalDoesNotOverrideValidatedEnv(t *testing.T) { + js.Global().Delete("megaportToken") + + t.Setenv("MEGAPORT_ACCESS_TOKEN", "") + t.Setenv("MEGAPORT_API_URL", "") + t.Setenv("MEGAPORT_ACCESS_KEY", "test-access-key") + t.Setenv("MEGAPORT_SECRET_KEY", "test-secret-key") + t.Setenv("MEGAPORT_ENVIRONMENT", "staging") + + // Global claims production; the validated env-var bucket (staging) must win. + credsObj := js.Global().Get("Object").New() + credsObj.Set("environment", "production") + js.Global().Set("megaportCredentials", credsObj) + defer js.Global().Delete("megaportCredentials") + + // A cached token short-circuits the network Authorize() call. + setTokenManager(t, "cached-token") + + // The credential success path logs location.origin, absent in the node + // test host; stub it so loginFunc can run to completion. + loc := js.Global().Get("Object").New() + loc.Set("origin", "https://portal.megaport.com") + js.Global().Set("location", loc) + defer js.Global().Delete("location") + + client, err := loginFunc(context.Background()) + require.NoError(t, err) + require.NotNil(t, client) + require.NotNil(t, client.BaseURL) + + assert.Equal(t, "api-staging.megaport.com", client.BaseURL.Host, + "credential login must use the validated env-var bucket, not the page-writable global") +} + // setTamperedTokenGlobal publishes a window.megaportToken global whose apiURL // field has been overwritten to an attacker-controlled host, mimicking a script // tampering with the page-writable global after a legitimate setAuthToken call. diff --git a/internal/wasm/wasm.go b/internal/wasm/wasm.go index 847049cf..d00d2ad0 100644 --- a/internal/wasm/wasm.go +++ b/internal/wasm/wasm.go @@ -85,6 +85,26 @@ func environmentFromHostname(hostname string) (string, bool) { return "", false } +// restrictEnvironmentNameStrict buckets an environment name into one of the +// three canonical values that downstream consumers of MEGAPORT_ENVIRONMENT +// understand ("production" / "staging" / "development"). "prod" is accepted +// as an alias for "production". Unlike restrictEnvironmentName, it reports +// whether the input matched a known bucket instead of silently defaulting, +// so callers that must reject unrecognized environments (e.g. +// setAuthCredentials) can fail closed. +func restrictEnvironmentNameStrict(env string) (string, bool) { + switch env { + case "production", "prod": + return "production", true + case "staging": + return "staging", true + case "development": + return "development", true + default: + return "", false + } +} + // restrictEnvironmentName collapses an environment name into one of the three // canonical values that downstream consumers of MEGAPORT_ENVIRONMENT understand // ("production" / "staging" / "development"). "prod" is accepted as an alias @@ -100,11 +120,8 @@ func environmentFromHostname(hostname string) (string, bool) { // difference is intentional and the divergence is exercised by tests in both // packages. func restrictEnvironmentName(env string) string { - if env == "production" || env == "prod" { - return "production" - } - if env == "staging" { - return "staging" + if bucket, ok := restrictEnvironmentNameStrict(env); ok { + return bucket } return "development" } @@ -873,6 +890,12 @@ func loadFromLocalStorage(this js.Value, args []js.Value) interface{} { // 1. Go environment variables (for os.Getenv calls) // 2. JavaScript global object (for direct access) // This avoids localStorage which is vulnerable to XSS attacks +// +// The environment argument is normalized (lowercased, trimmed) and must +// resolve to one of "production"/"staging"/"development" (see +// restrictEnvironmentNameStrict); anything else is rejected rather than +// stored, since an unrecognized value previously fell through to +// login_wasm.go's default case and routed credentials to production. func setAuthCredentials(this js.Value, args []js.Value) interface{} { if len(args) < 3 { return map[string]interface{}{ @@ -883,13 +906,21 @@ func setAuthCredentials(this js.Value, args []js.Value) interface{} { accessKey := args[0].String() secretKey := args[1].String() - environment := args[2].String() + environment := strings.ToLower(strings.TrimSpace(args[2].String())) + + bucket, ok := restrictEnvironmentNameStrict(environment) + if !ok { + return map[string]interface{}{ + "success": false, + "error": `environment must be one of "production", "staging", or "development"`, + } + } // Store credentials only in Go-side environment variables. // Do NOT mirror them into JS globals — any script on the page can read those. os.Setenv("MEGAPORT_ACCESS_KEY", accessKey) os.Setenv("MEGAPORT_SECRET_KEY", secretKey) - os.Setenv("MEGAPORT_ENVIRONMENT", environment) + os.Setenv("MEGAPORT_ENVIRONMENT", bucket) // Clear any token left over from a prior setAuthToken call: loginFunc // checks the token path first, so a stale token would otherwise keep @@ -900,13 +931,14 @@ func setAuthCredentials(this js.Value, args []js.Value) interface{} { // Expose only the non-secret environment name so the UI can reflect it. credentialsObj := js.Global().Get("Object").New() - credentialsObj.Set("environment", environment) + credentialsObj.Set("environment", bucket) js.Global().Set("megaportCredentials", credentialsObj) js.Global().Get("console").Call("log", "🔐 Credentials set (in-memory only)") return map[string]interface{}{ - "success": true, + "success": true, + "environment": bucket, } } diff --git a/internal/wasm/wasm_test.go b/internal/wasm/wasm_test.go index 23520c55..cf993fcd 100644 --- a/internal/wasm/wasm_test.go +++ b/internal/wasm/wasm_test.go @@ -10,6 +10,7 @@ package wasm import ( "bytes" "math" + "os" "strings" "syscall/js" "testing" @@ -898,6 +899,105 @@ func TestRestrictEnvironmentName(t *testing.T) { } } +// TestRestrictEnvironmentNameStrict verifies the strict variant used by +// setAuthCredentials: known buckets (plus the "prod" alias) resolve, and +// anything else is reported as unrecognized rather than defaulted. +func TestRestrictEnvironmentNameStrict(t *testing.T) { + tests := []struct { + env string + expected string + ok bool + }{ + {"production", "production", true}, + {"prod", "production", true}, + {"staging", "staging", true}, + {"development", "development", true}, + {"qa", "", false}, + {"uat", "", false}, + {"mpone-dev", "", false}, + {"Production", "", false}, // caller must normalize case before calling + {"", "", false}, + } + + for _, tt := range tests { + t.Run(tt.env, func(t *testing.T) { + bucket, ok := restrictEnvironmentNameStrict(tt.env) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.expected, bucket) + }) + } +} + +// TestSetAuthCredentials verifies that setAuthCredentials rejects any +// environment outside production/staging/development (normalizing case and +// whitespace first) instead of storing it verbatim, since login_wasm.go's +// credential path used to fall through to production for anything unrecognized. +func TestSetAuthCredentials(t *testing.T) { + RegisterJSFunctions() + defer js.Global().Get("clearAuthCredentials").Invoke() + + tests := []struct { + name string + environment string + expectError bool + expectedBucket string + }{ + {name: "production", environment: "production", expectedBucket: "production"}, + {name: "staging", environment: "staging", expectedBucket: "staging"}, + {name: "development", environment: "development", expectedBucket: "development"}, + {name: "prod alias", environment: "prod", expectedBucket: "production"}, + {name: "uppercase is normalized", environment: "STAGING", expectedBucket: "staging"}, + {name: "surrounding whitespace is trimmed", environment: " production ", expectedBucket: "production"}, + {name: "unknown environment is rejected, not routed to production", environment: "dev", expectError: true}, + {name: "unrecognized bucket-like value is rejected", environment: "qa", expectError: true}, + {name: "value that only partially matches a known bucket is rejected", environment: "production2", expectError: true}, + {name: "empty environment is rejected", environment: "", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + js.Global().Get("clearAuthCredentials").Invoke() + + setFunc := js.Global().Get("setAuthCredentials") + result := setFunc.Invoke("test-access-key", "test-secret-key", tt.environment) + + success := result.Get("success").Bool() + if tt.expectError { + assert.False(t, success, "should reject an unrecognized environment") + assert.NotEmpty(t, result.Get("error").String(), "error message should be set on failure") + assert.Empty(t, os.Getenv("MEGAPORT_ENVIRONMENT"), "MEGAPORT_ENVIRONMENT must not be set on failure") + assert.Empty(t, os.Getenv("MEGAPORT_ACCESS_KEY"), "credentials must not be stored when the environment is rejected") + } else { + assert.True(t, success, "should accept a recognized environment") + assert.Equal(t, tt.expectedBucket, result.Get("environment").String()) + assert.Equal(t, tt.expectedBucket, os.Getenv("MEGAPORT_ENVIRONMENT")) + assert.Equal(t, "test-access-key", os.Getenv("MEGAPORT_ACCESS_KEY")) + + credsGlobal := js.Global().Get("megaportCredentials") + assert.False(t, credsGlobal.IsUndefined()) + assert.Equal(t, tt.expectedBucket, credsGlobal.Get("environment").String()) + } + }) + } +} + +// TestSetAuthCredentials_MissingArgs verifies setAuthCredentials rejects a +// call missing accessKey, secretKey, or environment without touching any +// stored credentials. +func TestSetAuthCredentials_MissingArgs(t *testing.T) { + RegisterJSFunctions() + defer js.Global().Get("clearAuthCredentials").Invoke() + js.Global().Get("clearAuthCredentials").Invoke() + + setFunc := js.Global().Get("setAuthCredentials") + result := setFunc.Invoke("test-access-key", "test-secret-key") + + assert.False(t, result.Get("success").Bool()) + assert.NotEmpty(t, result.Get("error").String()) + assert.Empty(t, os.Getenv("MEGAPORT_ENVIRONMENT")) + assert.Empty(t, os.Getenv("MEGAPORT_ACCESS_KEY")) +} + // TestAuthMethodPriority verifies that token auth takes precedence over API key auth func TestAuthMethodPriority(t *testing.T) { EnableDebugMode()