Skip to content
Merged
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
42 changes: 34 additions & 8 deletions apps/core/server/api-elysia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,30 @@ const app = new Elysia()
.use(securityHeaders)
.use(
cors({
origin: (request) => {
// Build allowed origins list
origin: ({ request }) => {
// Get the incoming origin from the request
const incomingOrigin = request.headers.get("origin");

// No origin header means same-origin request or non-browser client
if (!incomingOrigin) {
return true;
}
Comment on lines +255 to +262
Copy link

Choose a reason for hiding this comment

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

Bug: The @elysiajs/cors origin callback incorrectly destructures ({ request }) instead of receiving the full Context object.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

The origin callback for @elysiajs/cors is incorrectly destructuring ({ request }) from its parameter. The callback expects a full Context object, not an object with a top-level request property. This causes request to be undefined, leading to a TypeError: Cannot read property 'headers' of undefined when request.headers.get("origin") is called, which will crash the server when processing CORS requests.

💡 Suggested Fix

Change the origin callback signature from ({ request }) => { ... } to (context) => { ... } and access the request via context.request.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/core/server/api-elysia.ts#L255-L262

Potential issue: The `origin` callback for `@elysiajs/cors` is incorrectly destructuring
`({ request })` from its parameter. The callback expects a full `Context` object, not an
object with a top-level `request` property. This causes `request` to be `undefined`,
leading to a `TypeError: Cannot read property 'headers' of undefined` when
`request.headers.get("origin")` is called, which will crash the server when processing
CORS requests.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 3979100


// Build allowed origins list (normalize by removing trailing slashes)
// Browser Origin headers never include trailing slashes, but env vars might
const normalizeOrigin = (origin: string) =>
origin.endsWith("/") ? origin.slice(0, -1) : origin;

const allowedOrigins: string[] = [];

// Add FRONTEND_URL if configured (required in production)
if (env.FRONTEND_URL) {
allowedOrigins.push(env.FRONTEND_URL);
allowedOrigins.push(normalizeOrigin(env.FRONTEND_URL));
}

// Add any additional CORS_ALLOWED_ORIGINS
if (env.CORS_ALLOWED_ORIGINS && env.CORS_ALLOWED_ORIGINS.length > 0) {
allowedOrigins.push(...env.CORS_ALLOWED_ORIGINS);
allowedOrigins.push(...env.CORS_ALLOWED_ORIGINS.map(normalizeOrigin));
}

// In development, allow localhost origins
Expand All @@ -276,16 +288,30 @@ const app = new Elysia()
);
}

// If no origins configured in production, reject (don't fall back to wildcard)
// If no origins configured in production, reject
if (allowedOrigins.length === 0) {
logger.warn(
{ context: "cors" },
"No CORS origins configured - rejecting cross-origin requests",
{ context: "cors", origin: incomingOrigin },
"No CORS origins configured - rejecting cross-origin request",
);
return false;
}
Comment on lines 292 to 298

Choose a reason for hiding this comment

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

Error Handling/UX Issue:
When no allowed origins are configured in production, the code logs a warning and returns false, but does not provide a clear error response to the client. This could lead to silent failures for cross-origin requests.

Recommended Solution:
Consider returning a more informative error response or status code to the client when CORS is misconfigured, to aid debugging and improve developer experience. For example, you could throw an error or return a custom message indicating that CORS is not configured.


return allowedOrigins;
// Check if incoming origin is in allowed list
const isAllowed = allowedOrigins.includes(incomingOrigin);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current check allowedOrigins.includes(incomingOrigin) is sensitive to trailing slashes. The Origin header from browsers typically does not include a trailing slash (e.g., https://example.com), but an origin configured in env.FRONTEND_URL or env.CORS_ALLOWED_ORIGINS might have one (e.g., https://example.com/). This discrepancy will cause the .includes() check to fail, incorrectly blocking valid cross-origin requests.

To make this check more robust, you should account for the potential trailing slash on the allowed origins during comparison.

        const isAllowed = allowedOrigins.some(allowedOrigin => allowedOrigin === incomingOrigin || (allowedOrigin.endsWith('/') && allowedOrigin.slice(0, -1) === incomingOrigin));

Comment on lines +300 to +301
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Prevent potential null value errors

Suggested change
// Check if incoming origin is in allowed list
const isAllowed = allowedOrigins.includes(incomingOrigin);
// Check if incoming origin is in allowed list
const isAllowed = !!incomingOrigin && allowedOrigins.includes(incomingOrigin);


if (!isAllowed) {
logger.warn(
{
context: "cors",
origin: incomingOrigin,
allowed: allowedOrigins,
},
"CORS request from unauthorized origin rejected",
);
}

return isAllowed;
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
Expand Down
13 changes: 10 additions & 3 deletions apps/core/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ import { getAuthToken } from "@/utils/auth-token-store";
// Get API base URL
// In development: Use http://localhost:3004 (direct connection to local backend)
// In production: Use VITE_API_URL if set, otherwise relative path /api
const API_BASE_URL = import.meta.env.DEV
// Remove trailing slash to prevent double-slash URLs (e.g., //api/users/me)
const rawBaseUrl = import.meta.env.DEV
? "http://localhost:3004" // Dev mode: Direct connection to local backend
: (import.meta.env.VITE_API_URL || "/api"); // Production: Use VITE_API_URL or relative path
: import.meta.env.VITE_API_URL || "/api"; // Production: Use VITE_API_URL or relative path
const API_BASE_URL = rawBaseUrl.endsWith("/")
? rawBaseUrl.slice(0, -1)
: rawBaseUrl;

// Debug logging
if (import.meta.env.DEV) {
console.log("[API Client] Dev mode - connecting to local backend, API_BASE_URL:", API_BASE_URL);
console.log(
"[API Client] Dev mode - connecting to local backend, API_BASE_URL:",
API_BASE_URL,
);
}

/**
Expand Down
93 changes: 53 additions & 40 deletions apps/core/src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { retryWithBackoff, RetryOptions } from './retry'
import { getAuthToken } from './auth-token-store'
import { retryWithBackoff, RetryOptions } from "./retry";
import { getAuthToken } from "./auth-token-store";

export interface RequestOptions extends RequestInit {
timeoutMs?: number
retry?: RetryOptions | boolean
timeoutMs?: number;
retry?: RetryOptions | boolean;
}

// Get API base URL for constructing full URLs
Expand All @@ -16,79 +16,92 @@ const getApiBaseUrl = (): string => {
}

// In production, use VITE_API_URL if set, otherwise relative URLs
return import.meta.env.VITE_API_URL || "";
}
// Remove trailing slash to prevent double-slash URLs (e.g., //api/users/me)
const baseUrl = import.meta.env.VITE_API_URL || "";
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
};

export async function apiFetch(
input: string,
init: RequestOptions = {},
): Promise<Response> {
Comment on lines +24 to +27
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find api-client.ts file
echo "=== Searching for api-client.ts ==="
find . -name "api-client.ts" -type f 2>/dev/null | head -20

# Find the specific file under review
echo -e "\n=== Searching for the api.ts file under review ==="
find . -path "*/core/src/utils/api.ts" -type f 2>/dev/null

Repository: Dexploarer/hyper-forge

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Read the api-client.ts file to check for apiFetch export
echo "=== Content of api-client.ts ==="
cat -n ./apps/core/src/lib/api-client.ts

echo -e "\n=== Content of api.ts (first 50 lines) ==="
head -50 ./apps/core/src/utils/api.ts

# Search for imports of apiFetch across the codebase
echo -e "\n=== Imports of apiFetch in codebase ==="
rg -n "import.*apiFetch|from.*api" --type=ts --type=tsx -A1 -B1 | head -100

Repository: Dexploarer/hyper-forge

Length of output: 11868


🏁 Script executed:

#!/bin/bash
# Search for imports of apiFetch - fix the file type filter
echo "=== Imports of apiFetch in codebase ==="
rg -n "import.*apiFetch" --type=ts -B2 -A2

echo -e "\n=== Files that import from api-client ==="
rg -n "from.*['\"].*api-client" --type=ts

echo -e "\n=== Files that import from utils/api ==="
rg -n "from.*['\"].*utils/api" --type=ts

Repository: Dexploarer/hyper-forge

Length of output: 11685


Two apiFetch exports with incompatible signatures create real import confusion.

Verified: PromptService.ts and CDNAdminService.ts import apiFetch from @/lib/api-client (returns Promise<{ data: T | null; error: string | null }>), while 19+ other files import from @/utils/api (returns Promise<Response>). This inconsistency means developers could easily pick the wrong import. Consider renaming one function to clarify intent—e.g., apiFetchWrapped or apiFetchRaw.

🤖 Prompt for AI Agents
In apps/core/src/utils/api.ts around lines 24 to 27, there is an apiFetch
exported that returns Promise<Response>, which conflicts with another apiFetch
exported from @/lib/api-client (returns Promise<{ data: T | null; error: string
| null }>) causing import confusion; rename this function to a clear, distinct
name (for example apiFetchRaw or fetchResponse) and update its export and all
internal imports/usages accordingly to preserve the original Response-returning
behavior, leaving the wrapped api-client export untouched so callers explicitly
choose raw Response vs wrapped data/error form.

const { timeoutMs = 15000, signal, retry: retryConfig, ...rest } = init;

export async function apiFetch(input: string, init: RequestOptions = {}): Promise<Response> {
const { timeoutMs = 15000, signal, retry: retryConfig, ...rest } = init

// Construct full URL if input is a relative path
// If input is already absolute (http:// or https://), use it as-is
// Otherwise, prepend base URL (empty string in dev/prod means relative URL)
const url = input.startsWith('http://') || input.startsWith('https://')
? input
: `${getApiBaseUrl()}${input.startsWith('/') ? input : `/${input}`}`

const baseUrl = getApiBaseUrl();
const url =
input.startsWith("http://") || input.startsWith("https://")
? input
: baseUrl && !input.startsWith("/")
? `${baseUrl}/${input}`
: `${baseUrl}${input}`;

const fetchWithTimeout = async (): Promise<Response> => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(new DOMException('Timeout', 'AbortError')), timeoutMs)
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(new DOMException("Timeout", "AbortError")),
timeoutMs,
);

try {
// Get auth token and add to headers
// Only set Authorization from global token if not already explicitly provided
const token = getAuthToken()
const token = getAuthToken();

// Convert rest.headers to a plain object, handling Headers objects, arrays, and plain objects
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};

if (rest.headers) {
if (rest.headers instanceof Headers) {
// Headers object - iterate over entries
rest.headers.forEach((value, key) => {
headers[key] = value
})
headers[key] = value;
});
} else if (Array.isArray(rest.headers)) {
// Array of [key, value] tuples
rest.headers.forEach(([key, value]) => {
headers[key] = value
})
headers[key] = value;
});
} else {
// Plain object
Object.assign(headers, rest.headers)
Object.assign(headers, rest.headers);
}
}
Comment on lines +54 to 71

Choose a reason for hiding this comment

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

Header Key Case Sensitivity and Overwrite Risk

The header merging logic does not normalize header keys to a consistent case, which can result in duplicate headers (e.g., both 'Authorization' and 'authorization') or missed overwrites. This can lead to security issues if the intended header is not set or is overridden unexpectedly.

Recommended Solution:
Normalize all header keys to a consistent case (e.g., lowercase) when merging, and check for the presence of the authorization header in a case-insensitive manner:

const normalizedHeaders = Object.fromEntries(
  Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
);

Then check for 'authorization' only.


// Only set Authorization header from global token if it's not already set
// This allows explicit Authorization headers (e.g., from accessToken parameter) to take precedence
// Check both 'Authorization' and 'authorization' for case-insensitivity
const hasAuthHeader = headers['Authorization'] || headers['authorization']
const hasAuthHeader =
headers["Authorization"] || headers["authorization"];
if (token && !hasAuthHeader) {
headers['Authorization'] = `Bearer ${token}`
headers["Authorization"] = `Bearer ${token}`;
}

const response = await fetch(url, {
...rest,
headers,
signal: signal ?? controller.signal
})
return response
signal: signal ?? controller.signal,

Choose a reason for hiding this comment

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

Potential Timeout Bypass Due to Signal Precedence

The code uses signal: signal ?? controller.signal, which means if a caller provides a signal, the internal timeout logic using controller.signal will be ignored. This can result in requests not being aborted after the specified timeout, potentially causing resource leaks or hanging requests.

Recommended Solution:
Consider always using the internal controller.signal for timeout, and if an external signal is provided, listen for its abort event and propagate it to the controller:

if (signal) {
  signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
}

Then always pass controller.signal to fetch.

});
return response;
} finally {
clearTimeout(timeout)
clearTimeout(timeout);
}
}
};

// Apply retry logic if enabled
if (retryConfig) {
const retryOptions = retryConfig === true ? {} : retryConfig
const result = await retryWithBackoff(fetchWithTimeout, retryOptions)
const retryOptions = retryConfig === true ? {} : retryConfig;
const result = await retryWithBackoff(fetchWithTimeout, retryOptions);

if (result.success && result.data) {
return result.data
return result.data;
}
throw result.error || new Error('Request failed after retries')

throw result.error || new Error("Request failed after retries");
}

// No retry - direct fetch
return fetchWithTimeout()
}
return fetchWithTimeout();
}
Loading