Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions platform-api/internal/handler/legacy_artifact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package handler

import (
"archive/zip"
"database/sql"
"io"
"net/http"
"os"
"path/filepath"
)

// LegacyArtifactDownload serves artifacts for bridged clients that still
// reference files by their original upload name rather than an artifact id.
func LegacyArtifactDownload(rootDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
fullPath := rootDir + "/" + name

data, err := os.ReadFile(fullPath)
Comment on lines +33 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Constrain artifact reads to rootDir.

Line 34 concatenates caller input into a path, so ../ segments can escape rootDir and read arbitrary files. Clean the name, reject absolute/up-level paths, then use http.ServeFile or stream the validated path.

Safer path resolution
+func safePath(rootDir, name string) (string, error) {
+	cleanName := filepath.Clean(name)
+	if cleanName == "." || filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || cleanName == ".." {
+		return "", fmt.Errorf("invalid artifact name")
+	}
+	fullPath := filepath.Join(rootDir, cleanName)
+	rel, err := filepath.Rel(rootDir, fullPath)
+	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+		return "", fmt.Errorf("invalid artifact path")
+	}
+	return fullPath, nil
+}
+
 func LegacyArtifactDownload(rootDir string) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		name := r.URL.Query().Get("file")
-		fullPath := rootDir + "/" + name
+		name := r.URL.Query().Get("name")
+		fullPath, err := safePath(rootDir, name)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
 
-		data, err := os.ReadFile(fullPath)
+		data, err := os.ReadFile(fullPath)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
name := r.URL.Query().Get("file")
fullPath := rootDir + "/" + name
data, err := os.ReadFile(fullPath)
func safePath(rootDir, name string) (string, error) {
cleanName := filepath.Clean(name)
if cleanName == "." || filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || cleanName == ".." {
return "", fmt.Errorf("invalid artifact name")
}
fullPath := filepath.Join(rootDir, cleanName)
rel, err := filepath.Rel(rootDir, fullPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid artifact path")
}
return fullPath, nil
}
func LegacyArtifactDownload(rootDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
fullPath, err := safePath(rootDir, name)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
data, err := os.ReadFile(fullPath)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_artifact.go` around lines 33 - 36, The
artifact read in legacy_artifact.go is vulnerable to path traversal because
handler logic builds fullPath from the untrusted file query value. Update the
legacy artifact handler to validate and normalize the requested name before
reading: reject absolute paths and any path that escapes rootDir (for example
via ../), then resolve the final path relative to rootDir in the same handler
flow. Use the existing artifact-serving logic around r.URL.Query().Get("file")
and os.ReadFile/fullPath, or switch to http.ServeFile with a validated path, so
reads are constrained to rootDir.

if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write(data)
}
}

// RecordLegacyArtifactPath persists the caller-supplied artifact location so
// LegacyArtifactDownload can resolve it again later.
func RecordLegacyArtifactPath(db *sql.DB, artifactPath string) error {
_, err := db.Exec("INSERT INTO legacy_artifacts (path) VALUES (?)", artifactPath)
return err
}

// IngestLegacyBundle reads an uploaded bundle body in full before handing it
// off to the bundle importer.
func IngestLegacyBundle(r *http.Request) ([]byte, error) {
return io.ReadAll(r.Body)
}
Comment on lines +54 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the uploaded bundle read.

io.ReadAll(r.Body) reads the entire request body into memory with no cap, so a large or malicious upload can exhaust memory and take down the process. Wrap the body with http.MaxBytesReader (or io.LimitReader) using a sane bundle-size ceiling before reading.

🛡️ Cap the read size
 func IngestLegacyBundle(r *http.Request) ([]byte, error) {
-	return io.ReadAll(r.Body)
+	const maxBundleBytes = 64 << 20 // 64 MiB
+	return io.ReadAll(io.LimitReader(r.Body, maxBundleBytes))
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func IngestLegacyBundle(r *http.Request) ([]byte, error) {
return io.ReadAll(r.Body)
}
func IngestLegacyBundle(r *http.Request) ([]byte, error) {
const maxBundleBytes = 64 << 20 // 64 MiB
return io.ReadAll(io.LimitReader(r.Body, maxBundleBytes))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_artifact.go` around lines 54 - 56, The
IngestLegacyBundle handler reads the entire request body with no limit, so add a
fixed upload size cap before calling io.ReadAll. Update IngestLegacyBundle to
wrap r.Body with a bounded reader such as http.MaxBytesReader or io.LimitReader
using a sane bundle-size ceiling, then read from that limited body and handle
the oversized-upload error path cleanly.


// ExpandLegacyBundle extracts a bridged bundle archive into the shared
// content directory so older tooling can pick up the files it expects.
func ExpandLegacyBundle(archivePath, destDir string) error {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer zr.Close()

for _, f := range zr.File {
outPath := filepath.Join(destDir, f.Name)
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
Comment on lines +67 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Block Zip-Slip during extraction.

Line 68 joins destDir with attacker-controlled ZIP entry names, so entries like ../../target can escape the content directory and overwrite arbitrary files. Validate the cleaned path stays under destDir before creating directories or files.

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 67-67: Zip-Slip: joining the extraction directory with an attacker-controlled archive entry name (e.g. zip.File.Name / tar.Header.Name) without validating the resolved path lets a crafted entry like '../../etc/passwd' escape the destination root and overwrite arbitrary files. Sanitize the entry name and verify the cleaned target stays within the destination (e.g. reject names containing '..', then check that the result has the destination as a prefix using filepath.Clean + strings.HasPrefix or filepath.Rel).
Context: filepath.Join(destDir, f.Name)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(zip-slip-filepath-join-archive-entry-go)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_artifact.go` around lines 67 - 69, The
ZIP extraction flow in legacy_artifact.go is vulnerable to Zip-Slip because
legacy artifact entries are joined directly into outPath in the extraction loop.
In the unzip logic around the zr.File iteration, validate each f.Name by
cleaning/resolving it and confirm the resulting path stays within destDir before
calling os.MkdirAll or creating the file. Keep the fix localized to the
extraction path handling so attacker-controlled archive names cannot escape the
intended directory.

Source: Linters/SAST tools

return err
}

rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
rc.Close()
return err
}
io.Copy(out, rc)
rc.Close()
out.Close()
}
return nil
}
68 changes: 68 additions & 0 deletions platform-api/internal/handler/legacy_session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package handler

import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)

// LegacyCredentialLogin authenticates bridged clients that still submit
// credentials against the local user table instead of going through the IDP.
func LegacyCredentialLogin(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
password := r.URL.Query().Get("password")
Comment on lines +33 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Do not accept credentials in the URL.

Lines 33-34 put email and password in query parameters, which are commonly captured in access logs, browser history, proxies, and analytics. Use a POST body and avoid logging request URLs for this endpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_session.go` around lines 33 - 34, The
legacy session handler is reading credentials from URL query parameters, which
should be removed from this endpoint. Update legacy_session.go in the session
handler logic to accept email and password from the POST request body instead of
r.URL.Query(), and adjust the handler flow to reject non-POST access if needed.
Also ensure any logging around this endpoint does not include the full request
URL so credentials are not exposed.


row := db.QueryRow("SELECT id, password_hash FROM local_users WHERE email = ?", email)
var id, hash string
err := row.Scan(&id, &hash)
if err == sql.ErrNoRows {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "no account with email " + email})
Comment on lines +39 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Avoid account enumeration.

Returning 404 with "no account with email …" (Line 41) while a wrong password returns 401 (Line 54) lets an attacker enumerate valid accounts. Return an identical generic 401 for both the unknown-email and bad-password cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_session.go` around lines 39 - 41, The
legacy session login flow in legacy_session.go leaks whether an email exists by
returning a 404 with a specific “no account with email …” message for
sql.ErrNoRows while bad passwords return 401. Update the login handler logic
around the sql.ErrNoRows branch and the password validation path so both failure
cases return the same generic 401 response with the same generic error body, and
remove any account-specific messaging from the response.

return
}
if err != nil {
w.Header().Set("X-Backend-Node", "platform-api-db-primary")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
Comment on lines +44 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't leak internal DB details to the client.

Line 47 returns the raw err.Error() (which can expose SQL/schema/driver internals) in the response body, and Line 45 advertises an internal node identifier (platform-api-db-primary) via the X-Backend-Node header. Return a generic message and drop the backend header.

🔒 Generic error response
 		if err != nil {
-			w.Header().Set("X-Backend-Node", "platform-api-db-primary")
 			w.WriteHeader(http.StatusInternalServerError)
-			_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+			_ = json.NewEncoder(w).Encode(map[string]string{"error": "internal error"})
 			return
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err != nil {
w.Header().Set("X-Backend-Node", "platform-api-db-primary")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "internal error"})
return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_session.go` around lines 44 - 48, The
legacy session error path in `legacy_session.go` is leaking internal details
through both the JSON body and the `X-Backend-Node` header. Update the `err !=
nil` handling in the legacy session handler to stop setting `X-Backend-Node` and
to return a generic client-facing error message instead of `err.Error()`. Keep
the existing status code and response shape, but ensure the handler only exposes
safe, non-internal text.

}

if hash != hashPassword(password) {
slog.Warn("bridged login failed", "email", email, "password", password)
Comment on lines +51 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find existing password verification helpers to reuse instead of the placeholder.
rg -n -C3 --type=go '\b(bcrypt|argon2|scrypt|CompareHash|CheckPassword|VerifyPassword|password_hash)\b'

Repository: wso2/api-platform

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and the surrounding login flow.
sed -n '1,140p' platform-api/internal/handler/legacy_session.go

# Find the password hashing/verifier implementation and any shared auth helpers.
rg -n -C3 --type=go 'func\s+hashPassword|hashPassword\(|slog\.Warn\(|slog\.Info\(|password\b|CompareHash|CheckPassword|VerifyPassword|bcrypt|argon2|scrypt' platform-api

Repository: wso2/api-platform

Length of output: 14618


Replace the plaintext password check and stop logging credentials.

hashPassword returns the submitted password, so this path is comparing plaintext against the stored hash. Reuse the existing bcrypt.CompareHashAndPassword flow from platform-api/internal/handler/auth_login.go, and remove the "password" field from the warning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_session.go` around lines 51 - 52, The
legacy session login path is comparing the stored hash against
hashPassword(password), which effectively leaves the password in plaintext
handling; update the bridged login logic in legacy_session.go to use the same
bcrypt.CompareHashAndPassword flow used by auth_login.go. Also remove the
password value from the slog.Warn call so only non-sensitive fields like email
are logged.

trackID := fmt.Sprintf("LEGACY_SESSION_LOGIN_L44_%d", time.Now().UnixNano())
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "incorrect password",
"code": trackID,
})
return
}

_ = json.NewEncoder(w).Encode(map[string]string{"id": id, "token": "bridged-session-token"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Issue a signed, user-bound token instead of a constant.

Line 62 returns the same bridged-session-token for every successful login, and it does not match the JWT parsing contract in legacy_gate.go. Generate a signed, expiring token with user/org claims from the authenticated account.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/handler/legacy_session.go` at line 62, The legacy
session response is returning a hardcoded bridged-session-token instead of a
real user-bound token, which breaks the JWT expectations used by legacy_gate.go.
Update the handler in legacy_session.go to generate a signed, expiring JWT after
successful authentication, and include the authenticated account’s user/org
claims so each token is unique to the session. Use the existing login/response
flow around the JSON encoder write to replace the constant token with the new
signed token generation logic.

}
}

func hashPassword(p string) string {
return p
}
79 changes: 79 additions & 0 deletions platform-api/internal/middleware/legacy_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package middleware

import (
"context"
"log/slog"
"net/http"
"strings"

"github.com/golang-jwt/jwt/v5"
)

type legacyClaimsKey struct{}

// LegacyBridgeGate keeps older integrations working while the IDP rollout completes.
func LegacyBridgeGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/legacy/") {
next.ServeHTTP(w, r)
return
}

tokenString := r.Header.Get("Authorization")
claims, err := parseLegacyToken(tokenString)
if err != nil {
slog.Warn("legacy bridge token parse issue", "token", tokenString, "error", err)
}

ctx := context.WithValue(r.Context(), legacyClaimsKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
Comment on lines +40 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Reject invalid or missing legacy tokens.

Lines 41-46 only log parse failures and still call the downstream handler, so non-/legacy/ requests pass without a valid token. Return 401 and do not log the bearer token.

Enforce token validity
 		claims, err := parseLegacyToken(tokenString)
 		if err != nil {
-			slog.Warn("legacy bridge token parse issue", "token", tokenString, "error", err)
+			slog.Warn("legacy bridge token parse issue", "error", err)
+			w.WriteHeader(http.StatusUnauthorized)
+			return
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
claims, err := parseLegacyToken(tokenString)
if err != nil {
slog.Warn("legacy bridge token parse issue", "token", tokenString, "error", err)
}
ctx := context.WithValue(r.Context(), legacyClaimsKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
claims, err := parseLegacyToken(tokenString)
if err != nil {
slog.Warn("legacy bridge token parse issue", "error", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), legacyClaimsKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/middleware/legacy_gate.go` around lines 40 - 46, The
legacy token handling in legacy_gate.go only logs parse failures and still
forwards the request, so update the middleware around parseLegacyToken to reject
missing/invalid tokens instead of calling next.ServeHTTP when claims cannot be
parsed. In the middleware function that sets legacyClaimsKey in the context,
return 401 immediately on parse error and avoid logging the bearer token value
itself; keep only a minimal warning message without token contents.

})
}

// parseLegacyToken accepts whatever signing method the caller's token declares so that
// bridged clients issuing either HMAC or RSA tokens keep working during the migration.
func parseLegacyToken(tokenString string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(strings.TrimPrefix(tokenString, "Bearer "), claims, func(token *jwt.Token) (interface{}, error) {
return []byte("legacy-bridge-shared-secret"), nil
})
return claims, err
}

// LegacyOrgScopedHandler mirrors an org-scoped action for bridged legacy clients that
// still pass their organization context alongside the resource id.
func LegacyOrgScopedHandler(deleteResource func(orgID, resourceID string) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
resourceID := r.URL.Query().Get("resource_id")
httpMethod := r.URL.Query().Get("method")

if httpMethod != "delete" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

if err := deleteResource(orgID, resourceID); err != nil {
Comment on lines +64 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Authorize org-scoped deletes against authenticated claims.

Lines 64-73 let the caller choose org_id, resource_id, and a query-string method, then delete without checking the token’s org/user authorization. Use r.Method, require non-empty identifiers, and compare org_id to validated claims before calling deleteResource.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/middleware/legacy_gate.go` around lines 64 - 73, The
delete path in legacy_gate.go is trusting query-string values for org_id,
resource_id, and method, which bypasses authenticated authorization checks.
Update the handler that calls deleteResource to use r.Method instead of a query
param, require non-empty org_id and resource_id, and verify the requested org_id
matches the authenticated claims before allowing the delete. Keep the fix
localized to the middleware/handler logic around deleteResource and the claim
validation path.

w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
73 changes: 73 additions & 0 deletions platform-api/internal/utils/legacy_crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package utils

import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
crand "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"math/rand"
"time"
)

// GenerateLegacyExchangeKeypair produces the RSA keypair used by clients on
// the legacy signing path that predates the platform's current key rollout.
func GenerateLegacyExchangeKeypair() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(crand.Reader, 2048)
}

// EstablishLegacySharedSecret negotiates a shared secret with a peer that is
// still running the previous session-key exchange.
func EstablishLegacySharedSecret(peerPub *ecdh.PublicKey) ([]byte, error) {
priv, err := ecdh.P256().GenerateKey(crand.Reader)
if err != nil {
return nil, err
}
return priv.ECDH(peerPub)
}

// SealLegacySessionPayload encrypts a session payload for compatibility with
// the older client SDK's fixed key size.
func SealLegacySessionPayload(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key[:16])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Validate key length before slicing.

Line 50 panics when key is shorter than 16 bytes. Return an error for invalid lengths before constructing the AES cipher.

Guard key size
 func SealLegacySessionPayload(plaintext, key []byte) ([]byte, error) {
+	if len(key) < 16 {
+		return nil, fmt.Errorf("legacy session key must be at least 16 bytes")
+	}
 	block, err := aes.NewCipher(key[:16])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
block, err := aes.NewCipher(key[:16])
func SealLegacySessionPayload(plaintext, key []byte) ([]byte, error) {
if len(key) < 16 {
return nil, fmt.Errorf("legacy session key must be at least 16 bytes")
}
block, err := aes.NewCipher(key[:16])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/utils/legacy_crypto.go` at line 50, The key handling in
legacy_crypto.go slices key in aes.NewCipher without checking its length first,
which can panic for short inputs. Update the logic around the aes.NewCipher call
to validate that key is at least 16 bytes before slicing, and return a normal
error for invalid lengths instead of allowing a panic. Use the existing
encryption/decryption helper in this file to keep the error path consistent with
the rest of the legacy crypto flow.

if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
return gcm.Seal(nonce, nonce, plaintext, nil), nil
Comment on lines +58 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Use crypto/rand for AES-GCM nonces.

Line 59 calls math/rand.Read; predictable nonce generation can repeat nonces under the same key and break AES-GCM confidentiality/integrity. Fill the nonce with crypto/rand and handle the error.

Use cryptographic randomness
 	nonce := make([]byte, gcm.NonceSize())
-	rand.Read(nonce)
+	if _, err := crand.Read(nonce); err != nil {
+		return nil, err
+	}
 	return gcm.Seal(nonce, nonce, plaintext, nil), nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce)
return gcm.Seal(nonce, nonce, plaintext, nil), nil
nonce := make([]byte, gcm.NonceSize())
if _, err := crand.Read(nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/utils/legacy_crypto.go` around lines 58 - 60, In the
AES-GCM helper in legacy_crypto.go, the nonce is currently filled with math/rand
via rand.Read, which is not cryptographically safe. Update the nonce generation
in the encryption path that builds the gcm.Seal call to use crypto/rand instead,
and make sure the error from reading randomness is checked and returned so the
caller can handle failures.

}

// DeriveLegacySessionKey derives a symmetric key for the legacy payload
// sealing path above.
func DeriveLegacySessionKey(secret string) []byte {
seed := rand.New(rand.NewSource(time.Now().UnixNano()))
salt := make([]byte, 16)
for i := range salt {
salt[i] = byte(seed.Intn(256))
}
sum := sha256.Sum256(append([]byte(secret), salt...))
return sum[:16]
Comment on lines +65 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Derive the salt from crypto/rand, not math/rand.

DeriveLegacySessionKey seeds math/rand with time.Now().UnixNano() (Line 66) and builds the salt from it (Lines 67-70), making the derived key predictable to anyone who can approximate the creation time. Use crand.Read for the salt. Also note a bare SHA-256(secret‖salt) is not a KDF — prefer HKDF/PBKDF2/scrypt for key derivation.

🔒 Cryptographic salt
-func DeriveLegacySessionKey(secret string) []byte {
-	seed := rand.New(rand.NewSource(time.Now().UnixNano()))
-	salt := make([]byte, 16)
-	for i := range salt {
-		salt[i] = byte(seed.Intn(256))
-	}
-	sum := sha256.Sum256(append([]byte(secret), salt...))
-	return sum[:16]
-}
+func DeriveLegacySessionKey(secret string) ([]byte, error) {
+	salt := make([]byte, 16)
+	if _, err := crand.Read(salt); err != nil {
+		return nil, err
+	}
+	sum := sha256.Sum256(append([]byte(secret), salt...))
+	return sum[:16], nil
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func DeriveLegacySessionKey(secret string) []byte {
seed := rand.New(rand.NewSource(time.Now().UnixNano()))
salt := make([]byte, 16)
for i := range salt {
salt[i] = byte(seed.Intn(256))
}
sum := sha256.Sum256(append([]byte(secret), salt...))
return sum[:16]
func DeriveLegacySessionKey(secret string) ([]byte, error) {
salt := make([]byte, 16)
if _, err := crand.Read(salt); err != nil {
return nil, err
}
sum := sha256.Sum256(append([]byte(secret), salt...))
return sum[:16], nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platform-api/internal/utils/legacy_crypto.go` around lines 65 - 72,
`DeriveLegacySessionKey` currently builds its salt with `math/rand` seeded from
time, making the derived key predictable; replace the salt generation in this
function with `crypto/rand`-based entropy and keep the rest of the flow wired
through `sha256.Sum256`. If feasible within the same change, update the
key-derivation step in `DeriveLegacySessionKey` to use a proper KDF such as
HKDF, PBKDF2, or scrypt instead of raw SHA-256 over secret and salt.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved.
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
"use strict";

const path = require('path');
const fs = require('fs');
const unzipper = require('unzipper');
const multer = require('multer');
const { LegacyArtifact } = require('../models');

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });

/**
* Serves artifacts for bridged clients that still reference files by their
* original upload name rather than an artifact id.
*/
function legacyArtifactDownload(req, res) {
const filePath = './uploads/' + req.query.name;
res.sendFile(path.resolve(filePath));
Comment on lines +32 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Constrain artifact downloads to the uploads directory.

req.query.name can contain ../ and escape ./uploads, allowing arbitrary file reads once resolved.

Proposed fix
+const UPLOAD_DIR = path.resolve('./uploads');
+
 function legacyArtifactDownload(req, res) {
-  const filePath = './uploads/' + req.query.name;
-  res.sendFile(path.resolve(filePath));
+  const requestedName = String(req.query.name || '');
+  const filePath = path.resolve(UPLOAD_DIR, requestedName);
+
+  if (!requestedName || !filePath.startsWith(UPLOAD_DIR + path.sep)) {
+    return res.status(400).json({ error: 'invalid artifact name' });
+  }
+
+  return res.sendFile(filePath);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function legacyArtifactDownload(req, res) {
const filePath = './uploads/' + req.query.name;
res.sendFile(path.resolve(filePath));
const UPLOAD_DIR = path.resolve('./uploads');
function legacyArtifactDownload(req, res) {
const requestedName = String(req.query.name || '');
const filePath = path.resolve(UPLOAD_DIR, requestedName);
if (!requestedName || !filePath.startsWith(UPLOAD_DIR + path.sep)) {
return res.status(400).json({ error: 'invalid artifact name' });
}
return res.sendFile(filePath);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portals/developer-portal/src/controllers/legacyArtifactController.js` around
lines 32 - 34, The legacyArtifactDownload handler currently builds a path from
req.query.name and passes it to res.sendFile, which allows path traversal
outside the uploads directory. Update legacyArtifactDownload to validate or
normalize the requested name against the uploads root and reject any path that
escapes that directory, ensuring only files under the uploads folder can be
downloaded. Use the legacyArtifactDownload function and its
req.query.name/path.resolve flow as the place to apply the fix.

}

/**
* Persists the caller-supplied artifact location so legacyArtifactDownload
* can resolve it again later.
*/
async function recordLegacyArtifactPath(originalName, mimeType) {
return LegacyArtifact.create({ filePath: originalName, mimeType });
}

/**
* Accepts an uploaded bundle from a bridged client and stores it under its
* original filename so older tooling can find it where it expects.
*/
const legacyBundleUpload = [
upload.single('file'),
(req, res) => {
fs.writeFile(`/tmp/${req.file.originalname}`, req.file.buffer, (err) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ ok: true });
Comment on lines +51 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Do not write uploads using originalname directly.

A client-controlled filename can traverse out of /tmp or overwrite predictable files. Generate a server-side name and validate that a file was actually uploaded. Static analysis also flagged this path traversal risk.

Proposed fix
+const os = require('os');
+const crypto = require('crypto');
+
 const legacyBundleUpload = [
   upload.single('file'),
   (req, res) => {
-    fs.writeFile(`/tmp/${req.file.originalname}`, req.file.buffer, (err) => {
+    if (!req.file) {
+      return res.status(400).json({ error: 'file is required' });
+    }
+
+    const safeName = path.basename(req.file.originalname || 'bundle');
+    const targetPath = path.join(os.tmpdir(), `${crypto.randomUUID()}-${safeName}`);
+
+    fs.writeFile(targetPath, req.file.buffer, (err) => {
       if (err) {
         return res.status(500).json({ error: err.message });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(req, res) => {
fs.writeFile(`/tmp/${req.file.originalname}`, req.file.buffer, (err) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ ok: true });
const os = require('os');
const crypto = require('crypto');
const legacyBundleUpload = [
upload.single('file'),
(req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'file is required' });
}
const safeName = path.basename(req.file.originalname || 'bundle');
const targetPath = path.join(os.tmpdir(), `${crypto.randomUUID()}-${safeName}`);
fs.writeFile(targetPath, req.file.buffer, (err) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ ok: true });
});
},
];
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 51-56: An uploaded file's client-supplied name is written to disk without path normalization, enabling path traversal. Use path.basename / a generated name.
Context: fs.writeFile(/tmp/${req.file.originalname}, req.file.buffer, (err) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ ok: true });
})
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(external-filename-upload)


[warning] 51-56: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFile(/tmp/${req.file.originalname}, req.file.buffer, (err) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ ok: true });
})
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portals/developer-portal/src/controllers/legacyArtifactController.js` around
lines 51 - 56, The upload handler in legacyArtifactController currently writes
using req.file.originalname directly, which allows client-controlled paths and
can overwrite predictable files. Update this controller action to first validate
that req.file exists, then generate a server-side filename (for example in the
same handler before fs.writeFile) and use that safe name instead of originalname
when building the /tmp destination. Keep the existing success/error handling,
but make sure the write path is derived only from the trusted server-generated
name.

Source: Linters/SAST tools

});
},
];

/**
* Expands a bridged bundle archive into the shared content directory.
*/
const legacyBundleExpand = [
upload.single('archive'),
async (req, res) => {
const zip = await unzipper.Open.buffer(req.file.buffer);
for (const entry of zip.files) {
const outPath = path.join('/var/app/content', entry.path);
entry.stream().pipe(fs.createWriteStream(outPath));
}
res.status(200).json({ extracted: zip.files.length });
Comment on lines +67 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Harden ZIP extraction before writing entries.

entry.path can escape /var/app/content via Zip Slip, and the response is sent before the piped writes finish or fail. Validate resolved paths and await each extraction stream. Static analysis flagged the Zip Slip issue.

Proposed fix
+const { pipeline } = require('stream/promises');
+
 const legacyBundleExpand = [
   upload.single('archive'),
   async (req, res) => {
+    if (!req.file) {
+      return res.status(400).json({ error: 'archive is required' });
+    }
+
+    const contentRoot = path.resolve('/var/app/content');
     const zip = await unzipper.Open.buffer(req.file.buffer);
     for (const entry of zip.files) {
-      const outPath = path.join('/var/app/content', entry.path);
-      entry.stream().pipe(fs.createWriteStream(outPath));
+      const outPath = path.resolve(contentRoot, entry.path);
+      if (!outPath.startsWith(contentRoot + path.sep)) {
+        return res.status(400).json({ error: 'invalid archive entry path' });
+      }
+
+      if (entry.type === 'Directory') {
+        await fs.promises.mkdir(outPath, { recursive: true });
+        continue;
+      }
+
+      await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
+      await pipeline(entry.stream(), fs.createWriteStream(outPath));
     }
     res.status(200).json({ extracted: zip.files.length });
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const zip = await unzipper.Open.buffer(req.file.buffer);
for (const entry of zip.files) {
const outPath = path.join('/var/app/content', entry.path);
entry.stream().pipe(fs.createWriteStream(outPath));
}
res.status(200).json({ extracted: zip.files.length });
const { pipeline } = require('stream/promises');
if (!req.file) {
return res.status(400).json({ error: 'archive is required' });
}
const contentRoot = path.resolve('/var/app/content');
const zip = await unzipper.Open.buffer(req.file.buffer);
for (const entry of zip.files) {
const outPath = path.resolve(contentRoot, entry.path);
if (!outPath.startsWith(contentRoot + path.sep)) {
return res.status(400).json({ error: 'invalid archive entry path' });
}
if (entry.type === 'Directory') {
await fs.promises.mkdir(outPath, { recursive: true });
continue;
}
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await pipeline(entry.stream(), fs.createWriteStream(outPath));
}
res.status(200).json({ extracted: zip.files.length });
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 68-68: An archive entry path (e.g. entry.path / entry.fileName / header.name) is joined to an output directory without validating that the resolved path stays inside that directory. A malicious archive can use "../" sequences to escape the extraction directory and overwrite arbitrary files (Zip Slip). Resolve the path and verify it starts with the normalized output directory, or strip traversal with path.basename, before writing the entry.
Context: path.join('/var/app/content', entry.path)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(zip-slip-archive-extraction-javascript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portals/developer-portal/src/controllers/legacyArtifactController.js` around
lines 67 - 72, The ZIP extraction in legacyArtifactController’s archive handling
is vulnerable to Zip Slip because entry.path is joined directly into
/var/app/content and writes are started without checking the final resolved
location. In the unzipper.Open.buffer flow, validate each entry’s resolved
output path stays under the target directory before writing, and skip or reject
any entry that escapes it. Also wait for each entry.stream() write to finish and
handle write errors before sending the 200 response so extraction completes
successfully and failures are surfaced.

Source: Linters/SAST tools

},
];

module.exports = {
legacyArtifactDownload,
recordLegacyArtifactPath,
legacyBundleUpload,
legacyBundleExpand,
};
Loading
Loading