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
18 changes: 16 additions & 2 deletions apps/server/src/cli/ConfigParser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'js-yaml';
import os from 'os';
import path from 'path';

export interface SuiConfigEnv {
alias: string;
Expand Down Expand Up @@ -57,6 +57,20 @@ export class ConfigParser {
}
}

/** The active environment's fullnode RPC URL, or null if there's no config or no match. */
public async getActiveRpcUrl(): Promise<string | null> {
try {
const config = await this.getConfig();
if (config) {
const activeEnv = config.envs.find((e) => e.alias === config.active_env);
return activeEnv?.rpc || null;
}
} catch {
// Ignore
}
return null;
}

public async saveConfig(updates: Partial<SuiConfig>): Promise<void> {
try {
const currentContent = await fs.promises.readFile(this.configPath, 'utf8');
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/cli/SuiCliExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export class SuiCliExecutor {
type: 'command_success',
command: args[0], // e.g., 'client', 'move'
duration,
metadata: { fullArgs: args },
// Only the command + subcommand, never the tail: `keytool import`
// takes a mnemonic or private key as a positional argument, and
// logging the full argv would write it to analytics.jsonl in plaintext.
metadata: { fullArgs: args.slice(0, 2) },
});

if (stdout.trim()) {
Expand All @@ -89,7 +92,7 @@ export class SuiCliExecutor {
command: args[0],
duration,
error: errorOutput,
metadata: { fullArgs: args },
metadata: { fullArgs: args.slice(0, 2) },
});

// Throw with the cleaned output so UI can display it properly
Expand All @@ -110,7 +113,9 @@ export class SuiCliExecutor {
}
return JSON.parse(jsonMatch[0]) as T;
} catch (error: any) {
throw new Error(`Failed to parse JSON output: ${error.message}\n\nOutput:\n${output.substring(0, 500)}`);
throw new Error(
`Failed to parse JSON output: ${error.message}\n\nOutput:\n${output.substring(0, 500)}`
);
}
}

Expand Down
66 changes: 56 additions & 10 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { replayRoutes } from './routes/replay';
import { securityRoutes } from './routes/security';
import { transferRoutes } from './routes/transfer';
import { walrusMemoryRoutes } from './routes/walrusMemory';
import { getOrCreateAuthToken } from './utils/authToken';
import { createRateLimitHook } from './utils/rateLimiter';

const require = createRequire(import.meta.url);
Expand All @@ -40,14 +41,37 @@ const CURRENT_VERSION = pkg.version;
const PACKAGE_NAME = pkg.name;

const PORT = parseInt(process.env.PORT || '3001', 10);
// Automatically bind to 0.0.0.0 in Railway/Cloud platforms or when HOST is set
const isCloud = !!(
process.env.RAILWAY_STATIC_URL ||
process.env.RAILWAY_SERVICE_ID ||
process.env.PORT
);
// Only a real Railway/cloud signal implies 0.0.0.0 - a user picking a
// non-default PORT for their own local install is not a cloud deployment,
// and treating it as one used to bind the wallet-management API to every
// network interface on their machine.
const isCloud = !!(process.env.RAILWAY_STATIC_URL || process.env.RAILWAY_SERVICE_ID);
const HOST = process.env.HOST || (isCloud ? '0.0.0.0' : '127.0.0.1');

/**
* True when the Host header names this server's own loopback address (or, on a
* hosted deployment, its own public domain).
*
* CORS alone is not enough: a page on any domain can point a DNS record at
* 127.0.0.1 with a short TTL, and once the browser resolves it the request is
* same-origin as far as the browser's Origin header goes, bypassing the CORS
* check entirely. Validating Host closes that gap.
*/
function isAllowedHost(hostHeader: string | undefined): boolean {
if (!hostHeader) return false;
const hostname = hostHeader.startsWith('[')
? hostHeader.slice(0, hostHeader.indexOf(']') + 1)
: hostHeader.split(':')[0];

const allowedHostnames = new Set(['localhost', '127.0.0.1', '[::1]']);
if (isCloud) {
for (const domain of [process.env.RAILWAY_PUBLIC_DOMAIN, process.env.RAILWAY_STATIC_URL]) {
if (domain) allowedHostnames.add(domain.replace(/^https?:\/\//, ''));
}
}
return allowedHostnames.has(hostname);
}

// Check for updates from npm registry
async function checkForUpdates(): Promise<{ hasUpdate: boolean; latestVersion: string | null }> {
try {
Expand Down Expand Up @@ -147,6 +171,15 @@ export async function buildServer() {
},
});

// Reject requests aimed at a Host other than this server's own address,
// before CORS or any route runs - see isAllowedHost() for why this exists.
fastify.addHook('onRequest', async (request, reply) => {
if (!isAllowedHost(request.headers.host)) {
reply.status(421);
return reply.send({ error: 'Invalid Host header' });
}
});

// Register CORS - allow localhost and the one hosted UI origin
await fastify.register(cors, {
origin: (origin, cb) => {
Expand Down Expand Up @@ -190,11 +223,17 @@ export async function buildServer() {
...envOrigins,
];

// Regex patterns for dynamic origins
// Regex patterns for dynamic origins. Scoped to this server's own PORT
// (it needs to allow its own served UI) plus the web workspace's Vite
// dev port - not "any localhost port", which would let any other local
// process or page (an unrelated dev server, a malicious postinstall
// script) drive key export and transaction signing just by running on
// the same machine.
const allowedPatterns = [
// Local development (allow any localhost port)
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
new RegExp(`^http://localhost:${PORT}$`),
new RegExp(`^http://127\\.0\\.0\\.1:${PORT}$`),
/^http:\/\/localhost:5174$/,
/^http:\/\/127\.0\.0\.1:5174$/,
];

// Check exact match
Expand Down Expand Up @@ -690,6 +729,13 @@ async function main() {
║ Keep this terminal open while using the app. ║
║ ║
╚═══════════════════════════════════════════════════════════════╝

🔑 Pairing token (required to export keys, sign, transfer, or pay):

${getOrCreateAuthToken()}

Paste this into the web UI when prompted. It is never sent anywhere
except this browser tab, and is only ever shown here in this terminal.
`);
} catch (err: any) {
if (err.code === 'EADDRINUSE') {
Expand Down
24 changes: 14 additions & 10 deletions apps/server/src/routes/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ApiResponse } from '@sui-cli-web/shared';
import { FastifyInstance } from 'fastify';
import { promises as fs, realpathSync } from 'fs';
import path from 'path';
import os from 'os';
import type { ApiResponse } from '@sui-cli-web/shared';
import path, { sep } from 'path';
import { handleRouteError } from '../utils/errorHandler';

interface DirectoryEntry {
Expand Down Expand Up @@ -78,11 +78,17 @@ function isPathAllowed(targetPath: string): boolean {
canonicalPath = path.normalize(path.resolve(targetPath));
}

// Check if canonical path starts with any allowed directory
// Check if canonical path is, or is inside, any allowed directory. The
// separator matters: without it, "/home/harry-evil" passes a startsWith
// check for "/home/harry".
for (const allowedDir of allowedDirs) {
// Normalize the allowed dir too
const normalizedAllowed = path.normalize(allowedDir);
if (canonicalPath.startsWith(normalizedAllowed)) {
if (
canonicalPath === normalizedAllowed ||
canonicalPath.startsWith(
normalizedAllowed.endsWith(sep) ? normalizedAllowed : normalizedAllowed + sep
)
) {
return true;
}
}
Expand Down Expand Up @@ -225,7 +231,8 @@ export async function filesystemRoutes(fastify: FastifyInstance) {
for (const file of files) {
if (!file.isDirectory()) continue;
if (file.name.startsWith('.')) continue; // Skip hidden
if (file.name === 'node_modules' || file.name === 'target' || file.name === 'build') continue; // Skip common non-package dirs
if (file.name === 'node_modules' || file.name === 'target' || file.name === 'build')
continue; // Skip common non-package dirs

const fullPath = path.join(dirPath, file.name);

Expand Down Expand Up @@ -284,10 +291,7 @@ export async function filesystemRoutes(fastify: FastifyInstance) {

// On Windows, add drive roots
if (process.platform === 'win32') {
commonDirs.push(
{ name: 'C: Drive', path: 'C:/' },
{ name: 'D: Drive', path: 'D:/' }
);
commonDirs.push({ name: 'C: Drive', path: 'C:/' }, { name: 'D: Drive', path: 'D:/' });
}

for (const dir of commonDirs) {
Expand Down
20 changes: 10 additions & 10 deletions apps/server/src/routes/key-management.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { FastifyInstance } from 'fastify';
import { KeyManagementService, EXPORT_WARNING } from '../services/KeyManagementService';
import type { ApiResponse } from '@sui-cli-web/shared';
import {
validateAddress,
validateOptionalAlias,
validateKeyScheme,
} from '../utils/validation';
import { FastifyInstance } from 'fastify';
import { EXPORT_WARNING, KeyManagementService } from '../services/KeyManagementService';
import { requireAuthToken } from '../utils/authToken';
import { handleRouteError } from '../utils/errorHandler';
import { validateAddress, validateKeyScheme, validateOptionalAlias } from '../utils/validation';

const keyManagementService = new KeyManagementService();

Expand Down Expand Up @@ -34,7 +31,7 @@ export async function keyManagementRoutes(fastify: FastifyInstance) {
publicKey: string;
warning: string;
}>;
}>('/keys/export', async (request, reply) => {
}>('/keys/export', { preHandler: requireAuthToken }, async (request, reply) => {
try {
// Validate address (can be address or alias)
const address = request.body?.address;
Expand Down Expand Up @@ -80,7 +77,7 @@ export async function keyManagementRoutes(fastify: FastifyInstance) {
alias?: string;
};
Reply: ApiResponse<{ address: string; alias?: string }>;
}>('/keys/import', async (request, reply) => {
}>('/keys/import', { preHandler: requireAuthToken }, async (request, reply) => {
try {
const { type, input, keyScheme, alias } = request.body || {};

Expand All @@ -100,7 +97,10 @@ export async function keyManagementRoutes(fastify: FastifyInstance) {
const validatedKeyScheme = validateKeyScheme(keyScheme);
if (!validatedKeyScheme) {
reply.status(400);
return { success: false, error: 'Valid key scheme is required (ed25519, secp256k1, secp256r1)' };
return {
success: false,
error: 'Valid key scheme is required (ed25519, secp256k1, secp256r1)',
};
}

// Validate alias (optional)
Expand Down
30 changes: 8 additions & 22 deletions apps/server/src/routes/package.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,21 @@
import type { ApiResponse } from '@sui-cli-web/shared';
import { FastifyInstance } from 'fastify';
import { ConfigParser } from '../cli/ConfigParser';
import { PackageService, PublishedPackageInfo } from '../services/dev/PackageService';
import type { ApiResponse } from '@sui-cli-web/shared';
import { handleRouteError } from '../utils/errorHandler';
import { getPackageModulesViaGrpc, type PackageModulesViaGrpc } from '../utils/suiGrpcClient';
import {
validateObjectId,
validateOptionalGasBudget,
validateModuleName,
validateFunctionName,
validateModuleName,
validateMoveArgs,
validateObjectId,
validateOptionalGasBudget,
validateTypeArgs,
} from '../utils/validation';
import { handleRouteError } from '../utils/errorHandler';
import { ConfigParser } from '../cli/ConfigParser';
import {
getPackageModulesViaGrpc,
type PackageModulesViaGrpc,
} from '../utils/suiGrpcClient';

const packageService = new PackageService();
const configParser = ConfigParser.getInstance();

/** Resolve the active environment's fullnode URL for gRPC introspection. */
async function getActiveRpcUrl(): Promise<string | null> {
try {
const config = await configParser.getConfig();
const activeEnv = config?.envs.find((e) => e.alias === config.active_env);
return activeEnv?.rpc || null;
} catch {
return null;
}
}

export async function packageRoutes(fastify: FastifyInstance) {
// Get user's published packages (via UpgradeCap objects)
fastify.get<{
Expand Down Expand Up @@ -64,7 +50,7 @@ export async function packageRoutes(fastify: FastifyInstance) {
try {
const packageId = validateObjectId(request.params.id, 'packageId');

const rpcUrl = await getActiveRpcUrl();
const rpcUrl = await configParser.getActiveRpcUrl();
if (!rpcUrl) {
reply.status(503);
return { success: false, error: 'No active Sui environment configured' };
Expand Down
11 changes: 6 additions & 5 deletions apps/server/src/routes/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
* Pay Routes - Multi-recipient payments
*/

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { PayService, PayRequest, PayAllSuiRequest } from '../services/dev/PayService';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { PayAllSuiRequest, PayRequest, PayService } from '../services/dev/PayService';
import { requireAuthToken } from '../utils/authToken';
import { handleRouteError } from '../utils/errorHandler';
import { validateAddress } from '../utils/validation';

Expand All @@ -13,7 +14,7 @@ export async function payRoutes(fastify: FastifyInstance) {
// POST /api/pay - Pay using any coins
fastify.post<{
Body: PayRequest;
}>('/pay', async (request, reply) => {
}>('/pay', { preHandler: requireAuthToken }, async (request, reply) => {
try {
// Validate recipients
if (request.body.recipients) {
Expand All @@ -37,7 +38,7 @@ export async function payRoutes(fastify: FastifyInstance) {
// POST /api/pay/sui - Pay using SUI coins
fastify.post<{
Body: PayRequest;
}>('/pay/sui', async (request, reply) => {
}>('/pay/sui', { preHandler: requireAuthToken }, async (request, reply) => {
try {
// Validate recipients
if (request.body.recipients) {
Expand All @@ -61,7 +62,7 @@ export async function payRoutes(fastify: FastifyInstance) {
// POST /api/pay/all-sui - Pay all SUI to one recipient
fastify.post<{
Body: PayAllSuiRequest;
}>('/pay/all-sui', async (request, reply) => {
}>('/pay/all-sui', { preHandler: requireAuthToken }, async (request, reply) => {
try {
if (request.body.recipient) {
validateAddress(request.body.recipient, 'recipient');
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/routes/ptb-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
* PTB Builder Routes - Visual PTB construction and execution
*/

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { PtbBuilderService, PtbCommand, PtbBuildRequest } from '../services/dev/PtbBuilderService';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { PtbBuilderService, PtbBuildRequest, PtbCommand } from '../services/dev/PtbBuilderService';
import { requireAuthToken } from '../utils/authToken';
import { handleRouteError } from '../utils/errorHandler';

export async function ptbBuilderRoutes(fastify: FastifyInstance) {
Expand All @@ -24,7 +25,7 @@ export async function ptbBuilderRoutes(fastify: FastifyInstance) {
// POST /api/ptb/build - Build and execute PTB
fastify.post<{
Body: PtbBuildRequest;
}>('/inspector/ptb-builder/build', async (request, reply) => {
}>('/inspector/ptb-builder/build', { preHandler: requireAuthToken }, async (request, reply) => {
try {
const result = await service.executePtb(request.body);
return result;
Expand Down
Loading
Loading