Skip to content
15 changes: 1 addition & 14 deletions src/frontend/packages/core/src/core/utils.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({});
Expand Down
14 changes: 0 additions & 14 deletions src/frontend/packages/core/src/core/utils.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = object>(value: string): T | null {
try {
if (value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ function doMergeObjects(src: Record<string, unknown>, dest: Record<string, unkno
}

Object.keys(dest).forEach(key => {
// 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] = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/jetstream/datastore/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
54 changes: 42 additions & 12 deletions src/jetstream/plugins/backup/backup_restore.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down
62 changes: 62 additions & 0 deletions src/jetstream/plugins/backup/backup_restore_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions src/jetstream/plugins/cloudfoundryhosting/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions src/jetstream/plugins/kubernetes/terminal/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"

Expand Down Expand Up @@ -114,10 +115,18 @@
// 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}
Comment thread
norman-abramovitz marked this conversation as resolved.
Dismissed
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://") {
Expand Down
11 changes: 11 additions & 0 deletions src/jetstream/plugins/kubernetes/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ type KubeTerminal struct {
Image string `configName:"STRATOS_KUBERNETES_TERMINAL_IMAGE"`
Token []byte
APIServer string
CACert []byte
Kube api.Kubernetes
}

Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion src/jetstream/setup_console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tools/stb/src/projection/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ function namespaceFor(snapshotId: string, containers: Record<string, string>): s

function setPath(obj: Record<string, unknown>, 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]!;
Expand Down
Loading