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
38 changes: 34 additions & 4 deletions frontend/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import {
executePurchase,
getPurchaseDetails,
cancelPurchase,
getPublicInfo
getPublicInfo,
getDeploymentInfo
} from '../api';
import type { CreatePlanRequest, Config, Recommendation } from '../api';

Expand Down Expand Up @@ -963,14 +964,18 @@ describe('Purchase API', () => {

describe('Public Info API', () => {
describe('getPublicInfo', () => {
test('fetches public info without auth', async () => {
test('fetches version and admin_exists without auth (#633: no sensitive fields)', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: '1.0.0', admin_exists: true, api_key_secret_url: 'https://...' })
json: () => Promise.resolve({ version: '1.0.0', admin_exists: true })
});

const info = await getPublicInfo();
expect(info.api_key_secret_url).toBeTruthy();
expect(info.version).toBe('1.0.0');
expect(info.admin_exists).toBe(true);
// Sensitive fields must not be present on the public endpoint response.
expect((info as unknown as Record<string, unknown>)['api_key_secret_url']).toBeUndefined();
expect((info as unknown as Record<string, unknown>)['deployment_aws_account_id']).toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/info');
});

Expand All @@ -982,4 +987,29 @@ describe('Public Info API', () => {
expect(info.admin_exists).toBe(false);
});
});

describe('getDeploymentInfo', () => {
test('fetches api_key_secret_url and deployment_aws_account_id with auth', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
api_key_secret_url: 'https://us-east-1.console.aws.amazon.com/secretsmanager/secret?name=arn:...',
deployment_aws_account_id: '123456789012',
})
});

const info = await getDeploymentInfo();
expect(info.api_key_secret_url).toContain('secretsmanager');
expect(info.deployment_aws_account_id).toBe('123456789012');
expect(fetchMock).toHaveBeenCalledWith('/api/info/deployment', expect.objectContaining({}));
});

test('returns empty object on error', async () => {
fetchMock.mockResolvedValue({ ok: false });

const info = await getDeploymentInfo();
expect(info.api_key_secret_url).toBeUndefined();
expect(info.deployment_aws_account_id).toBeUndefined();
});
});
});
17 changes: 17 additions & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
LoginResponse,
User,
PublicInfo,
DeploymentInfo,
MFASetupResponse,
MFARecoveryCodesResponse,
MFALoginErrorCode,
Expand Down Expand Up @@ -370,3 +371,19 @@ export async function getPublicInfo(): Promise<PublicInfo> {
}
return { version: '', admin_exists: false };
}

/**
* Get deployment info (AuthUser required). Returns sensitive identifiers
* (API key secret URL, deployment AWS account ID) that must not be exposed
* to unauthenticated callers (#633).
*/
export async function getDeploymentInfo(): Promise<DeploymentInfo> {
const API_BASE = getApiBase();
const response = await fetch(`${API_BASE}/info/deployment`, {
headers: getAuthHeaders(),
});
if (response.ok) {
return response.json() as Promise<DeploymentInfo>;
}
return {};
}
2 changes: 2 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
Config,
ServiceConfig,
PublicInfo,
DeploymentInfo,
PurchaseResult,
PurchaseDetails,
PlannedPurchasesResponse,
Expand Down Expand Up @@ -86,6 +87,7 @@ export {
setupAdmin,
changePassword,
getPublicInfo,
getDeploymentInfo,
// MFA lifecycle (issue #497)
MFALoginError,
setupMFA,
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,16 @@ export interface ServiceConfig {
exclude_types?: string[];
}

/** Response from GET /api/info (unauthenticated). Only safe-to-expose fields. */
export interface PublicInfo {
version: string;
admin_exists: boolean;
}

/** Response from GET /api/info/deployment (AuthUser). Sensitive identifiers
* that must not be visible to unauthenticated callers (#633). */
export interface DeploymentInfo {
/** AWS Console deep-link to the Secrets Manager secret holding the API key. */
api_key_secret_url?: string;
/** AWS account ID of the CUDly Lambda (from STS GetCallerIdentity). Present
* only on AWS-hosted deployments; omitted on Azure/GCP or when STS is
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export async function init(): Promise<void> {
try {
const publicInfo = await api.getPublicInfo();
if (!publicInfo.admin_exists) {
await showAdminSetupModal(publicInfo.api_key_secret_url);
// api_key_secret_url is no longer served on the unauthenticated
// /api/info endpoint (#633). The admin setup modal works without
// the hint — the deployer can find the secret in the AWS console.
await showAdminSetupModal(undefined);
return;
}
} catch {
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/approval-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,20 +313,24 @@ export async function buildApprovalDetailsBody(executionId: string): Promise<HTM
// renders the full details when either endpoint is unreachable; the
// per-rec table degrades gracefully. console.warn keeps failures
// traceable rather than silently dropping them.
const [details, accounts, publicInfo] = await Promise.all([
const [details, accounts, deploymentInfo] = await Promise.all([
api.getPurchaseDetails(executionId),
api.listAccounts().catch((err) => {
console.warn('Failed to load accounts for approval modal — falling back to UUID-prefixed labels:', err);
return [] as CloudAccount[];
}),
api.getPublicInfo().catch((err) => {
console.warn('Failed to load public info for approval modal — orphan label will show "Account deleted":', err);
// getDeploymentInfo requires an authenticated session (AuthUser). The
// approval modal is only reachable post-login, so this is safe. On
// failure (e.g. token expiry) the orphan label degrades to "Account deleted"
// which is the safe default (#633).
api.getDeploymentInfo().catch((err) => {
console.warn('Failed to load deployment info for approval modal — orphan label will show "Account deleted":', err);
return undefined;
}),
]);
const accountsById = new Map<string, CloudAccount>();
for (const acct of accounts) accountsById.set(acct.id, acct);
const hostAWSAccountID = publicInfo?.deployment_aws_account_id;
const hostAWSAccountID = deploymentInfo?.deployment_aws_account_id;
return renderApprovalDetailsBody(details, accountsById, hostAWSAccountID);
} catch (err) {
console.error('Failed to load purchase details for approval modal:', err);
Expand Down
21 changes: 16 additions & 5 deletions internal/api/handler_dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,10 @@ func (h *Handler) planIntersectsAllowed(ctx context.Context, planID string, allo
return false, nil
}

// getPublicInfo returns public information about the CUDly instance (no auth required)
// getPublicInfo returns public information about the CUDly instance (no auth required).
// No rate limiting — this is hit by Terraform deployment checks and the frontend on every page load.
// Sensitive identifiers (API key secret URL, deployment AWS account ID) are intentionally
// absent here; they live on the authenticated GET /api/info/deployment endpoint (#633).
func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionURLRequest) (*PublicInfoResponse, error) {
// Check if admin exists
adminExists := false
Expand All @@ -343,7 +345,18 @@ func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionU
}
}

// Build the API key secret URL for the console
return &PublicInfoResponse{
Version: "1.0.0",
AdminExists: adminExists,
}, nil
}

// getDeploymentInfo returns sensitive deployment identifiers for authenticated callers.
// Requires at least AuthUser (enforced by the router). The two fields it returns
// expose the AWS account ID and the Secrets Manager ARN path — neither should be
// reachable without a valid session (#633).
func (h *Handler) getDeploymentInfo(ctx context.Context, _ *events.LambdaFunctionURLRequest) (*DeploymentInfoResponse, error) {
// Build the AWS Console deep-link to the Secrets Manager secret.
var apiKeySecretURL string
if h.secretsARN != "" {
// Extract region from ARN: arn:aws:secretsmanager:region:account:secret:name
Expand All @@ -364,9 +377,7 @@ func (h *Handler) getPublicInfo(ctx context.Context, req *events.LambdaFunctionU
// warning label, which is safe.
deploymentAWSAccountID, _ := h.resolveAWSAccountID(ctx)

return &PublicInfoResponse{
Version: "1.0.0",
AdminExists: adminExists,
return &DeploymentInfoResponse{
APIKeySecretURL: apiKeySecretURL,
DeploymentAWSAccountID: deploymentAWSAccountID,
}, nil
Expand Down
83 changes: 61 additions & 22 deletions internal/api/handler_dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"encoding/json"
"errors"
"testing"
"time"
Expand Down Expand Up @@ -546,8 +547,6 @@ func TestHandler_getPublicInfo(t *testing.T) {

assert.Equal(t, "1.0.0", result.Version)
assert.True(t, result.AdminExists)
assert.Contains(t, result.APIKeySecretURL, "us-east-1")
assert.Contains(t, result.APIKeySecretURL, "secretsmanager")
})

t.Run("with auth service and no admin", func(t *testing.T) {
Expand All @@ -562,7 +561,6 @@ func TestHandler_getPublicInfo(t *testing.T) {
require.NoError(t, err)

assert.False(t, result.AdminExists)
assert.Empty(t, result.APIKeySecretURL)
})

t.Run("auth service check error still returns response", func(t *testing.T) {
Expand All @@ -589,53 +587,94 @@ func TestHandler_getPublicInfo(t *testing.T) {
assert.False(t, result.AdminExists)
})

t.Run("ARN parsing for different regions", func(t *testing.T) {
t.Run("with rate limiting - allowed", func(t *testing.T) {
mockAuth := new(MockAuthService)
mockRateLimiter := new(MockRateLimiter)
mockAuth.On("CheckAdminExists", ctx).Return(true, nil)
mockRateLimiter.On("AllowWithIP", ctx, "192.168.1.1", "api_general").Return(true, nil)

handler := &Handler{
auth: mockAuth,
secretsARN: "arn:aws:secretsmanager:eu-west-1:987654321098:secret:my-secret-xyz789",
auth: mockAuth,
rateLimiter: mockRateLimiter,
}

result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1"))
require.NoError(t, err)

assert.Contains(t, result.APIKeySecretURL, "eu-west-1")
assert.True(t, result.AdminExists)
})

t.Run("invalid ARN format", func(t *testing.T) {
// Regression test for #633: sensitive identifiers must never appear in the
// unauthenticated /api/info response, even when a secretsARN is configured.
t.Run("no sensitive fields in unauthenticated response (#633)", func(t *testing.T) {
mockAuth := new(MockAuthService)
mockAuth.On("CheckAdminExists", ctx).Return(true, nil)

handler := &Handler{
auth: mockAuth,
secretsARN: "invalid-arn",
secretsARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key-abc123",
}

result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1"))
result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("10.0.0.1"))
require.NoError(t, err)

// Invalid ARN should result in empty URL
assert.Empty(t, result.APIKeySecretURL)
// PublicInfoResponse no longer carries these fields — the struct itself is
// the compile-time guard. The JSON assertion catches any future re-addition
// via an embedded struct or interface{} workaround.
assert.Equal(t, "1.0.0", result.Version)
assert.True(t, result.AdminExists)
encoded, jsonErr := json.Marshal(result)
require.NoError(t, jsonErr)
body := string(encoded)
assert.NotContains(t, body, "api_key_secret_url", "api_key_secret_url must not appear in /api/info response")
assert.NotContains(t, body, "deployment_aws_account_id", "deployment_aws_account_id must not appear in /api/info response")
})
}

t.Run("with rate limiting - allowed", func(t *testing.T) {
mockAuth := new(MockAuthService)
mockRateLimiter := new(MockRateLimiter)
mockAuth.On("CheckAdminExists", ctx).Return(true, nil)
mockRateLimiter.On("AllowWithIP", ctx, "192.168.1.1", "api_general").Return(true, nil)
func TestHandler_getDeploymentInfo(t *testing.T) {
ctx := context.Background()

t.Run("returns ARN-derived URL and account ID", func(t *testing.T) {
handler := &Handler{
auth: mockAuth,
rateLimiter: mockRateLimiter,
secretsARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key-abc123",
}

result, err := handler.getPublicInfo(ctx, createMockLambdaRequest("192.168.1.1"))
result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1"))
require.NoError(t, err)
assert.True(t, result.AdminExists)

assert.Contains(t, result.APIKeySecretURL, "us-east-1")
assert.Contains(t, result.APIKeySecretURL, "secretsmanager")
})

t.Run("ARN parsing for different regions", func(t *testing.T) {
handler := &Handler{
secretsARN: "arn:aws:secretsmanager:eu-west-1:987654321098:secret:my-secret-xyz789",
}

result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1"))
require.NoError(t, err)

assert.Contains(t, result.APIKeySecretURL, "eu-west-1")
})

t.Run("invalid ARN format returns empty URL", func(t *testing.T) {
handler := &Handler{
secretsARN: "invalid-arn",
}

result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1"))
require.NoError(t, err)

assert.Empty(t, result.APIKeySecretURL)
})

t.Run("empty secretsARN returns empty URL", func(t *testing.T) {
handler := &Handler{}

result, err := handler.getDeploymentInfo(ctx, createMockLambdaRequest("10.0.0.1"))
require.NoError(t, err)

assert.Empty(t, result.APIKeySecretURL)
})
}

func TestHandler_calculateCommitmentMetrics(t *testing.T) {
Expand Down
Loading
Loading