File size: 10,350 Bytes
383cb38 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import { Piece } from '@activepieces/pieces-framework';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
const app = express();
const PORT = process.env.PORT || 3003;
// ============================================================================
// SECURITY: Authentication Middleware
// ============================================================================
const PIECE_ENGINE_API_KEY = process.env.PIECE_ENGINE_API_KEY || '';
const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true' || PIECE_ENGINE_API_KEY.length > 0;
/**
* Authentication middleware for management endpoints.
* Validates API key via X-API-Key header or Authorization: Bearer <key>
*/
function authenticateRequest(req: Request, res: Response, next: NextFunction): void {
// Skip authentication if not explicitly enabled
if (!REQUIRE_AUTH) {
next();
return;
}
// Try X-API-Key header first
const apiKey = req.headers['x-api-key'] as string;
// Try Authorization header (Bearer token)
const authHeader = req.headers['authorization'] as string;
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
const token = apiKey || bearerToken;
if (!token) {
res.status(401).json({
success: false,
error: 'Authentication required. Provide X-API-Key header or Authorization: Bearer <key>.'
});
return;
}
if (token !== PIECE_ENGINE_API_KEY) {
res.status(403).json({
success: false,
error: 'Invalid API key'
});
return;
}
next();
}
// NPM package name validation (RFC compliance)
// Based on: https://github.com/npm/validate-npm-package-name
const VALID_PACKAGE_NAME_REGEX = /^(?:@([a-z0-9-~][a-z0-9-._~]*)\/)?([a-z0-9-~][a-z0-9-._~]*)$/;
/**
* Validates an npm package name against RFC standards to prevent command injection.
* @param packageName - The package name to validate
* @returns True if the package name is valid, false otherwise
*/
function isValidPackageName(packageName: string): boolean {
if (!packageName || typeof packageName !== 'string') {
return false;
}
// Check length (npm limits to 214 chars)
if (packageName.length > 214) {
return false;
}
// Validate against RFC-compliant regex
return VALID_PACKAGE_NAME_REGEX.test(packageName);
}
/**
* Safely installs an npm package using spawn (no shell interpretation).
* @param packageName - The validated package name to install
* @returns Promise that resolves when installation completes
*/
function safeNpmInstall(packageName: string): Promise<void> {
return new Promise((resolve, reject) => {
// Use spawn with argument array to prevent shell injection
const process = spawn('npm', ['install', packageName, '--save'], {
stdio: 'inherit',
shell: false // Critical: Disable shell to prevent command injection
});
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`npm install exited with code ${code}`));
}
});
process.on('error', (err) => {
reject(new Error(`Failed to spawn npm process: ${err.message}`));
});
});
}
app.use(express.json());
app.use(cors());
// In-memory piece registry
const pieces: Record<string, any> = {};
// Helper to load a piece safely
const loadPiece = async (pieceName: string): Promise<Piece | null> => {
try {
console.log(`Attempting to load piece: ${pieceName}`);
// SECURITY: Validate package name before any operations
if (!isValidPackageName(pieceName)) {
console.error(`Invalid package name: ${pieceName}`);
return null;
}
// Try to import directly
let module;
try {
module = await import(pieceName);
} catch (importErr) {
console.log(`Module ${pieceName} not found. Attempting dynamic install...`);
// Use safe installation method with spawn instead of exec
await safeNpmInstall(pieceName);
module = await import(pieceName);
}
const piece = module.piece || module.default?.piece || module.default;
if (piece && typeof piece === 'object' && piece.displayName) {
pieces[pieceName] = piece;
return piece;
}
console.warn(`Module ${pieceName} loaded but no Piece export found.`);
return null;
} catch (e: any) {
console.error(`Failed to load/install piece ${pieceName}:`, e.message);
return null;
}
};
// Bootstrap: Load all pieces defined in package.json
const bootstrap = async () => {
try {
const packageJsonPath = path.join(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = packageJson.dependencies || {};
const pieceNames = Object.keys(deps).filter(d => d.startsWith('@activepieces/piece-'));
console.log(`Found ${pieceNames.length} pieces to bootstrap...`);
for (const name of pieceNames) {
await loadPiece(name);
}
console.log(`Bootstrap complete. ${Object.keys(pieces).length} pieces ready.`);
} catch (err) {
console.error('Bootstrap failed:', err);
}
};
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'ok',
pieces_loaded: Object.keys(pieces).length,
loaded_names: Object.keys(pieces)
});
});
// Endpoint to list available pieces (metadata only)
app.get('/pieces', (req: Request, res: Response) => {
const metadata = Object.entries(pieces).map(([name, p]) => {
const actions = typeof p.actions === 'function' ? p.actions() : (p as any).actions || {};
const triggers = typeof p.triggers === 'function' ? p.triggers() : (p as any).triggers || {};
return {
name: name,
displayName: p.displayName,
logoUrl: p.logoUrl,
version: (p as any).version || '0.0.0',
actions: Object.keys(actions),
triggers: Object.keys(triggers),
};
});
res.json(metadata);
});
// Endpoint to get full details for a specific piece
// SECURITY: Require authentication for dynamic piece loading (can trigger npm install)
app.get('/pieces/:name', authenticateRequest, async (req: Request, res: Response) => {
const name = encodeURIComponent(req.params.name);
// Explicitly allow dots and slashes in piece names if they aren't caught by express router correctly
// but usually @activepieces/piece-foo works as req.params.name
let piece = pieces[req.params.name];
if (!piece) {
piece = await loadPiece(req.params.name) || null;
}
if (!piece) {
return res.status(404).json({ error: 'Piece not found' });
}
const actions = typeof piece.actions === 'function' ? piece.actions() : (piece as any).actions || {};
const triggers = typeof piece.triggers === 'function' ? piece.triggers() : (piece as any).triggers || {};
res.json({
name: req.params.name,
displayName: piece.displayName,
logoUrl: piece.logoUrl,
authors: piece.authors,
actions: actions,
triggers: triggers,
auth: piece.auth
});
});
// Endpoint to execute an action
// SECURITY: Require authentication for action execution
app.post('/execute/action', authenticateRequest, async (req: Request, res: Response) => {
try {
const { pieceName, actionName, props, auth } = req.body;
let piece = pieces[pieceName];
if (!piece) {
piece = await loadPiece(pieceName) || null;
}
if (!piece) {
return res.status(404).json({ error: `Piece ${pieceName} not found` });
}
const actions = typeof piece.actions === 'function' ? piece.actions() : (piece as any).actions || {};
const action = actions[actionName];
if (!action) {
return res.status(404).json({ error: `Action ${actionName} not found in piece ${pieceName}` });
}
// Context Preparation
const context = {
propsValue: props || {},
auth: auth || {},
store: {
put: async () => { },
get: async () => null,
delete: async () => { },
} as any,
webhookUrl: '',
files: {
write: async () => '',
} as any,
serverUrl: '',
project: { id: 'atom-project' } as any,
flow: { id: 'atom-flow' } as any
};
const result = await action.run(context);
res.json({ success: true, output: result });
} catch (error: any) {
console.error('Execution error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Management: Install a piece
// SECURITY: Require authentication for package installation
app.post('/sys/install', authenticateRequest, async (req: Request, res: Response) => {
const { packageName } = req.body;
// SECURITY: Validate package name before installation
if (!packageName || !isValidPackageName(packageName)) {
return res.status(400).json({
success: false,
error: 'Invalid package name. Package names must follow npm naming conventions.'
});
}
try {
console.log(`Installing ${packageName}...`);
// Use safe installation method with spawn instead of exec
await safeNpmInstall(packageName);
const piece = await loadPiece(packageName);
if (piece) {
res.json({ success: true, message: `Installed and loaded ${packageName}` });
} else {
res.json({ success: false, error: 'Package installed but no piece export found' });
}
} catch (e: any) {
res.status(500).json({ success: false, error: e.message });
}
});
app.listen(PORT, async () => {
console.log(`Atom Piece Engine running on port ${PORT}`);
await bootstrap();
});
|