Skip to content
8 changes: 4 additions & 4 deletions frontend-integration/types/megaport-wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 27 additions & 12 deletions internal/commands/config/login_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
}
}
}
Expand All @@ -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")
Expand All @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions internal/commands/config/login_wasm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package config
import (
"context"
"net/http"
"os"
"syscall/js"
"testing"
"time"
Expand All @@ -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.
Expand Down
50 changes: 41 additions & 9 deletions internal/wasm/wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}
Expand Down Expand Up @@ -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{}{
Expand All @@ -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
Expand All @@ -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,
}
}

Expand Down
Loading
Loading