diff --git a/src/frontend/packages/core/src/core/utils.service.spec.ts b/src/frontend/packages/core/src/core/utils.service.spec.ts index 829a9faf59..a4bbff8f2a 100644 --- a/src/frontend/packages/core/src/core/utils.service.spec.ts +++ b/src/frontend/packages/core/src/core/utils.service.spec.ts @@ -3,7 +3,7 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import { Subscription } from 'rxjs'; -import { pathGet, pathSet, safeStringToObj, safeUnsubscribe, UtilsService } from './utils.service'; +import { pathGet, safeStringToObj, safeUnsubscribe, UtilsService } from './utils.service'; describe('UtilsService', () => { let service: UtilsService; @@ -143,19 +143,6 @@ describe('UtilsService', () => { }); }); - describe('#pathSet', () => { - it('should set object value based on path ', () => { - const obj: any = { a: { b: {} } }; - pathSet('a.b', obj, 1); - - expect(obj.a.b).toBe(1); - }); - - it('should throw exception if path doesnt exist', () => { - expect(() => pathSet('a.b', {}, 1)).toThrowError(); - }); - }); - describe('#safeStringToObj', () => { it('should convert json obj string to object', () => { expect(safeStringToObj('{}')).toEqual({}); diff --git a/src/frontend/packages/core/src/core/utils.service.ts b/src/frontend/packages/core/src/core/utils.service.ts index 81b5fa95b2..24aa9eeba8 100644 --- a/src/frontend/packages/core/src/core/utils.service.ts +++ b/src/frontend/packages/core/src/core/utils.service.ts @@ -257,20 +257,6 @@ export function pathGet(path: string, object: any): any { return (index && index === length) ? object : undefined; } -export function pathSet(path: string, object: any, value: any) { - const params = path.split('.'); - - let index = 0; - const length = params.length - 1; - - while (object !== null && object !== undefined && index < length) { - object = object[params[index++]]; - } - if ((index && index === length)) { - object[params[index++]] = value; - } -} - export function safeStringToObj(value: string): T | null { try { if (value) { diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts index 1b5086935f..6ba6df0060 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts @@ -18,6 +18,10 @@ function doMergeObjects(src: Record, dest: Record { + // Guard against prototype pollution from parsed YAML/JSON keys + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return; + } if (typeof (dest[key]) === 'object' && !Array.isArray(dest)) { if (!src[key]) { src[key] = {}; diff --git a/src/frontend/packages/store/src/entity-catalog/entity-catalog.ts b/src/frontend/packages/store/src/entity-catalog/entity-catalog.ts index c8cc6cac1d..a5a88568b7 100644 --- a/src/frontend/packages/store/src/entity-catalog/entity-catalog.ts +++ b/src/frontend/packages/store/src/entity-catalog/entity-catalog.ts @@ -473,13 +473,15 @@ export class EntityCatalog { } else if (this.lookupStats.failure <= 5) { // For first few failures without debug mode, provide a hint about enabling debug console.warn( - `Missing catalog entity: endpoint='${config.endpointType}', entity='${config.entityType}'${config.subType ? `, subType='${config.subType}'` : ''}`, + 'Missing catalog entity:', + `endpoint='${config.endpointType}', entity='${config.entityType}'${config.subType ? `, subType='${config.subType}'` : ''}`, `\nℹ️ Enable detailed diagnostics with: window.__STRATOS_ENTITY_CATALOG_DEBUG__ = {}` ); } else { // Use concise warning after several failures to reduce console noise console.warn( - `Missing catalog entity: endpoint='${config.endpointType}', entity='${config.entityType}'${config.subType ? `, subType='${config.subType}'` : ''}` + 'Missing catalog entity:', + `endpoint='${config.endpointType}', entity='${config.entityType}'${config.subType ? `, subType='${config.subType}'` : ''}` ); } } else { @@ -493,7 +495,8 @@ export class EntityCatalog { } catch (error) { this.lookupStats.failure++; console.error( - `Error getting catalog entity: endpoint='${endpointTypeOrConfig}', entity='${entityType}'`, + 'Error getting catalog entity:', + `endpoint='${endpointTypeOrConfig}', entity='${entityType}'`, error ); return null; diff --git a/src/jetstream/datastore/datastore.go b/src/jetstream/datastore/datastore.go index 715e3f3e25..d008654c68 100644 --- a/src/jetstream/datastore/datastore.go +++ b/src/jetstream/datastore/datastore.go @@ -161,7 +161,8 @@ func NewDatabaseConnectionParametersFromConfig(dc DatabaseConfig) (DatabaseConfi // Invalid SSL mode return dc, fmt.Errorf("invalid SSL mode: %s", dc.SSLMode) } - return dc, fmt.Errorf("invalid provider %v", dc) + // Only name the provider — dc holds the database password + return dc, fmt.Errorf("invalid database provider %q", dc.DatabaseProvider) } func validateRequiredDatabaseParams(username, password, database, host string, port int) (err error) { diff --git a/src/jetstream/plugins/backup/backup_restore.go b/src/jetstream/plugins/backup/backup_restore.go index 516ed78dd2..31922fbf80 100644 --- a/src/jetstream/plugins/backup/backup_restore.go +++ b/src/jetstream/plugins/backup/backup_restore.go @@ -1,13 +1,18 @@ package backup import ( + "bytes" + "crypto/rand" "crypto/sha256" "database/sql" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" + "golang.org/x/crypto/pbkdf2" + "github.com/cloudfoundry/stratos/src/jetstream/api" "github.com/cloudfoundry/stratos/src/jetstream/crypto" "github.com/labstack/echo/v4" @@ -284,13 +289,22 @@ func deSerializeEndpoint(endpoint map[string]interface{}) api.CNSIRecord { return cnsi } +// Key-derivation parameters for password-protected backups. Backups made +// before the PBKDF2 format carry no magic prefix and decrypt via the legacy +// single-round SHA256 path below. +var backupKDFMagic = []byte("STRATOS-BACKUP-V2:") + +const ( + backupKDFSaltLen = 16 + backupKDFIterations = 600000 // OWASP recommendation for PBKDF2-HMAC-SHA256 +) + func encryptPayload(payload *BackupContentPayload, password string) ([]byte, error) { - // First ensure the password is an ok length - secret, err := createHash(password) - if err != nil { - log.Warningf("Could not create hash: %+v", err) - return nil, fmt.Errorf("Could not create hash") + salt := make([]byte, backupKDFSaltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("Could not generate salt: %+v", err) } + secret := pbkdf2.Key([]byte(password), salt, backupKDFIterations, 32, sha256.New) // Create the text that will be encrypted payloadBytes, err := json.Marshal(payload) @@ -304,15 +318,30 @@ func encryptPayload(payload *BackupContentPayload, password string) ([]byte, err return nil, fmt.Errorf("Could not encrypt payload: %+v", err) } - return payloadEncrypted, nil + // magic + salt + ciphertext, so restore can find the salt again + out := append([]byte{}, backupKDFMagic...) + out = append(out, salt...) + out = append(out, payloadEncrypted...) + return out, nil } func decryptPayload(payloadEncrypted []byte, password string) (*string, error) { - // First ensure the password is an ok length - secret, err := createHash(password) - if err != nil { - log.Warningf("Could not create hash: %+v", err) - return nil, fmt.Errorf("Could not create hash") + var secret []byte + if bytes.HasPrefix(payloadEncrypted, backupKDFMagic) { + rest := payloadEncrypted[len(backupKDFMagic):] + if len(rest) < backupKDFSaltLen { + return nil, fmt.Errorf("Failed to decrypt payload: truncated backup") + } + secret = pbkdf2.Key([]byte(password), rest[:backupKDFSaltLen], backupKDFIterations, 32, sha256.New) + payloadEncrypted = rest[backupKDFSaltLen:] + } else { + // Legacy backup from before the PBKDF2 format + var err error + secret, err = createHash(password) + if err != nil { + log.Warningf("Could not create hash: %+v", err) + return nil, fmt.Errorf("Could not create hash") + } } payloadUnencrypted, err := crypto.DecryptToken(secret, payloadEncrypted) @@ -323,7 +352,8 @@ func decryptPayload(payloadEncrypted []byte, password string) (*string, error) { return &payloadUnencrypted, nil } -// createHash - Ensure the token used by crypto is at an acceptable length +// createHash - legacy key derivation, kept so pre-PBKDF2 backups can still be +// restored func createHash(password string) ([]byte, error) { // Create a hash long enough to ensure with use AES-256 hasher := sha256.New() diff --git a/src/jetstream/plugins/backup/backup_restore_test.go b/src/jetstream/plugins/backup/backup_restore_test.go new file mode 100644 index 0000000000..ab6ac3e57b --- /dev/null +++ b/src/jetstream/plugins/backup/backup_restore_test.go @@ -0,0 +1,62 @@ +package backup + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/cloudfoundry/stratos/src/jetstream/crypto" +) + +func TestEncryptDecryptPayloadRoundTrip(t *testing.T) { + payload := &BackupContentPayload{ + Endpoints: []map[string]interface{}{{"name": "test-endpoint"}}, + } + + encrypted, err := encryptPayload(payload, "correct horse battery staple") + if err != nil { + t.Fatalf("encryptPayload failed: %v", err) + } + + decrypted, err := decryptPayload(encrypted, "correct horse battery staple") + if err != nil { + t.Fatalf("decryptPayload failed: %v", err) + } + if !strings.Contains(*decrypted, "test-endpoint") { + t.Fatalf("decrypted payload missing content: %s", *decrypted) + } + + // AES-CFB is unauthenticated: a wrong password yields garbage rather + // than an error (restore detects it at JSON parse) + garbage, err := decryptPayload(encrypted, "wrong password") + if err == nil && strings.Contains(*garbage, "test-endpoint") { + t.Fatal("wrong password recovered the plaintext") + } +} + +func TestDecryptPayloadLegacyFormat(t *testing.T) { + // Backups created before the PBKDF2 format: single-round SHA256 key, + // no magic prefix + payloadBytes, err := json.Marshal(&BackupContentPayload{ + Endpoints: []map[string]interface{}{{"name": "legacy-endpoint"}}, + }) + if err != nil { + t.Fatal(err) + } + secret, err := createHash("legacy password") + if err != nil { + t.Fatal(err) + } + legacy, err := crypto.EncryptToken(secret, string(payloadBytes)) + if err != nil { + t.Fatal(err) + } + + decrypted, err := decryptPayload(legacy, "legacy password") + if err != nil { + t.Fatalf("decryptPayload failed on legacy format: %v", err) + } + if !strings.Contains(*decrypted, "legacy-endpoint") { + t.Fatalf("decrypted legacy payload missing content: %s", *decrypted) + } +} diff --git a/src/jetstream/plugins/cloudfoundryhosting/main.go b/src/jetstream/plugins/cloudfoundryhosting/main.go index 6b53f63cec..9bd5cce3b7 100644 --- a/src/jetstream/plugins/cloudfoundryhosting/main.go +++ b/src/jetstream/plugins/cloudfoundryhosting/main.go @@ -294,6 +294,8 @@ func (ch *CFHosting) SessionEchoMiddleware(h echo.HandlerFunc) echo.HandlerFunc // Set the JSESSIONID coolie for Cloud Foundry session affinity w := c.Response().Writer cookie := sessions.NewCookie(cfSessionCookieName, sessionGUID, session.Options) + // CF hosting always terminates TLS (X-Forwarded-Proto is enforced above) + cookie.Secure = true http.SetCookie(w, cookie) } return h(c) diff --git a/src/jetstream/plugins/kubernetes/terminal/start.go b/src/jetstream/plugins/kubernetes/terminal/start.go index aa50e902c8..f6e85b26a6 100644 --- a/src/jetstream/plugins/kubernetes/terminal/start.go +++ b/src/jetstream/plugins/kubernetes/terminal/start.go @@ -2,6 +2,7 @@ package terminal import ( "crypto/tls" + "crypto/x509" "errors" "fmt" @@ -114,10 +115,18 @@ func (k *KubeTerminal) Start(c echo.Context) error { // API Endpoint to SSH/exec into a container target := fmt.Sprintf("%s/api/v1/namespaces/%s/pods/%s/exec?command=/bin/bash&stdin=true&stderr=true&stdout=true&tty=true", k.APIServer, k.Namespace, podData.PodName) + // Verify the API server against the in-cluster CA; only skip + // verification in the dev setup where no CA file is mounted + tlsConfig := &tls.Config{InsecureSkipVerify: true} + if len(k.CACert) > 0 { + pool := x509.NewCertPool() + if pool.AppendCertsFromPEM(k.CACert) { + tlsConfig = &tls.Config{RootCAs: pool} + } + } + dialer := &websocket.Dialer{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, + TLSClientConfig: tlsConfig, } if strings.HasPrefix(target, "https://") { diff --git a/src/jetstream/plugins/kubernetes/terminal/terminal.go b/src/jetstream/plugins/kubernetes/terminal/terminal.go index 74b91ad36a..d44d23e916 100644 --- a/src/jetstream/plugins/kubernetes/terminal/terminal.go +++ b/src/jetstream/plugins/kubernetes/terminal/terminal.go @@ -13,6 +13,7 @@ import ( const ( serviceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + serviceAccountCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" serviceHostEnvVar = "KUBERNETES_SERVICE_HOST" servicePortEnvVar = "KUBERNETES_SERVICE_PORT" // For dev - read token from env var @@ -32,6 +33,7 @@ type KubeTerminal struct { Image string `configName:"STRATOS_KUBERNETES_TERMINAL_IMAGE"` Token []byte APIServer string + CACert []byte Kube api.Kubernetes } @@ -80,6 +82,15 @@ func NewKubeTerminal(p jetstream_api.PortalProxy) *KubeTerminal { kt.Token = token + // Read the in-cluster CA so the API server's certificate can be verified. + // Absent only in the env-var-token dev setup, which falls back to + // skipping verification. + if caCert, err := ioutil.ReadFile(serviceAccountCAFile); err == nil { + kt.CACert = caCert + } else { + log.Warnf("Unable to load Kubernetes CA certificate - API server TLS verification disabled. %v", err) + } + log.Debug("Kubernetes Terminal configured") return kt } diff --git a/src/jetstream/setup_console.go b/src/jetstream/setup_console.go index ed1e94eda2..50d8f43e76 100644 --- a/src/jetstream/setup_console.go +++ b/src/jetstream/setup_console.go @@ -163,7 +163,8 @@ func saveLocalUserConsoleConfig(consoleRepo console_config.Repository, consoleCo } func saveUAAConsoleConfig(consoleRepo console_config.Repository, consoleConfig *api.ConsoleConfig) error { - log.Debugf("Saving ConsoleConfig: %+v", consoleConfig) + // Don't dump the whole struct — it contains the client secret + log.Debugf("Saving ConsoleConfig for UAA endpoint: %v", consoleConfig.UAAEndpoint) if err := consoleRepo.SetValue(systemGroupName, "UAA_ENDPOINT", consoleConfig.UAAEndpoint.String()); err != nil { return err diff --git a/tools/stb/src/projection/projector.ts b/tools/stb/src/projection/projector.ts index 9078e58a55..931890e98c 100644 --- a/tools/stb/src/projection/projector.ts +++ b/tools/stb/src/projection/projector.ts @@ -66,6 +66,8 @@ function namespaceFor(snapshotId: string, containers: Record): s function setPath(obj: Record, path: string, value: unknown): void { const parts = path.split('.'); + // Guard against prototype pollution via crafted path segments + if (parts.some((p) => p === '__proto__' || p === 'constructor' || p === 'prototype')) return; let cur = obj; for (let i = 0; i < parts.length - 1; i++) { const k = parts[i]!;