diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 000769c5..3f5f9a0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,11 +56,16 @@ jobs: electron-cache-${{ runner.os }}- - run: pnpm install --frozen-lockfile - run: pnpm run build - - name: Package (code signing) - run: pnpm run package - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + - name: Package + shell: pwsh + run: | + if ("${{ secrets.CSC_LINK }}") { + $env:CSC_LINK = "${{ secrets.CSC_LINK }}" + } + if ("${{ secrets.CSC_KEY_PASSWORD }}") { + $env:CSC_KEY_PASSWORD = "${{ secrets.CSC_KEY_PASSWORD }}" + } + pnpm run package -- --publish never - name: Generate checksums shell: pwsh run: | @@ -102,19 +107,28 @@ jobs: electron-cache-${{ runner.os }}- - run: pnpm install --frozen-lockfile - run: pnpm run build - - name: Package (code signing & notarization) + - name: Package run: | + if [ -n "${{ secrets.CSC_LINK }}" ]; then + export CSC_LINK="${{ secrets.CSC_LINK }}" + fi + if [ -n "${{ secrets.CSC_KEY_PASSWORD }}" ]; then + export CSC_KEY_PASSWORD="${{ secrets.CSC_KEY_PASSWORD }}" + fi + if [ -n "${{ secrets.APPLE_ID }}" ]; then + export APPLE_ID="${{ secrets.APPLE_ID }}" + fi + if [ -n "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" ]; then + export APPLE_APP_SPECIFIC_PASSWORD="${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" + fi + if [ -n "${{ secrets.APPLE_TEAM_ID }}" ]; then + export APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" + fi if [ -n "$APPLE_ID" ] && [ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] && [ -n "$APPLE_TEAM_ID" ]; then echo "Notarization secrets found, enabling notarize" npx json -I -f electron-builder.json -e 'this.mac.notarize=true' fi - pnpm run package - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + pnpm run package -- --publish never - name: Generate checksums run: | cd release @@ -156,7 +170,7 @@ jobs: electron-cache-${{ runner.os }}- - run: pnpm install --frozen-lockfile - run: pnpm run build - - run: pnpm run package + - run: pnpm run package -- --publish never - name: Generate checksums run: | cd release diff --git a/electron/ipc/deploy.ts b/electron/ipc/deploy.ts index 35494662..4dde9da6 100644 --- a/electron/ipc/deploy.ts +++ b/electron/ipc/deploy.ts @@ -1,20 +1,36 @@ -import { ipcMain } from 'electron' +import { ipcMain, dialog } from 'electron' import { ipcHandler } from '../services/IpcHandlerFactory' import * as DeployService from '../services/DeployService' import type { DeployPlatform, VercelLink, RailwayLink } from '../shared/types' +async function confirmTokenStorage(platform: 'vercel' | 'railway'): Promise { + const label = platform === 'vercel' ? 'Vercel' : 'Railway' + const { response } = await dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', `Store ${label} Token`], + defaultId: 0, + cancelId: 0, + title: `Connect ${label}`, + message: `Store a ${label} access token in DAEMON?`, + detail: 'This grants deployment-related access from the renderer through the preload bridge.', + }) + if (response === 0) throw new Error(`${label} connection cancelled by user`) +} + export function registerDeployHandlers() { ipcMain.handle('deploy:auth-status', ipcHandler(async () => { return DeployService.getAuthStatus() })) ipcMain.handle('deploy:connect-vercel', ipcHandler(async (_event, token: string) => { + await confirmTokenStorage('vercel') const user = await DeployService.validateVercelToken(token) DeployService.storeToken('vercel', token) return user })) ipcMain.handle('deploy:connect-railway', ipcHandler(async (_event, token: string) => { + await confirmTokenStorage('railway') const user = await DeployService.validateRailwayToken(token) DeployService.storeToken('railway', token) return user diff --git a/electron/ipc/email.ts b/electron/ipc/email.ts index e9cbd939..e68a2f7c 100644 --- a/electron/ipc/email.ts +++ b/electron/ipc/email.ts @@ -17,6 +17,7 @@ import { updateSettings, } from '../services/EmailService' import { ipcHandler } from '../services/IpcHandlerFactory' +import { ValidationService } from '../services/ValidationService' export function registerEmailHandlers() { ipcMain.handle('email:accounts', ipcHandler(async () => { @@ -52,7 +53,35 @@ export function registerEmailHandlers() { })) ipcMain.handle('email:send', ipcHandler(async (_event, accountId: string, to: string, subject: string, body: string, cc?: string, bcc?: string) => { - return await sendEmail(accountId, { to, subject, body, cc, bcc }) + const accountResult = ValidationService.validateString(accountId, 1, 256) + if (!accountResult.success) throw new Error(accountResult.errors?.[0] ?? 'Invalid account ID') + + const toResult = ValidationService.validateEmailList(to, true) + if (!toResult.success) throw new Error(toResult.errors?.[0] ?? 'Invalid recipient') + + const ccResult = ValidationService.validateEmailList(cc) + if (!ccResult.success) throw new Error(ccResult.errors?.[0] ?? 'Invalid cc recipient') + + const bccResult = ValidationService.validateEmailList(bcc) + if (!bccResult.success) throw new Error(bccResult.errors?.[0] ?? 'Invalid bcc recipient') + + const subjectResult = ValidationService.validateString(subject, 1, 998) + if (!subjectResult.success) throw new Error(subjectResult.errors?.[0] ?? 'Invalid subject') + + const bodyResult = ValidationService.validateString(body, 1, 100_000) + if (!bodyResult.success) throw new Error(bodyResult.errors?.[0] ?? 'Invalid body') + + if (!ValidationService.checkRateLimit(`email-send:${accountResult.data}`, 10, 60_000)) { + throw new Error('Email send rate limit exceeded for this account. Please wait a minute.') + } + + return await sendEmail(accountResult.data!, { + to: toResult.data!, + subject: subjectResult.data!, + body: bodyResult.data!, + cc: ccResult.data, + bcc: bccResult.data, + }) })) ipcMain.handle('email:mark-read', ipcHandler(async (_event, accountId: string, messageIds: string[]) => { diff --git a/electron/ipc/vault.ts b/electron/ipc/vault.ts index 4a22a8ee..eaebd4e9 100644 --- a/electron/ipc/vault.ts +++ b/electron/ipc/vault.ts @@ -6,6 +6,24 @@ import * as Vault from '../services/VaultService' const MAX_FILE_SIZE = 1024 * 1024 // 1 MB — vault is for keys/creds, not large files +async function confirmVaultAction(options: { + title: string + message: string + detail: string + confirmLabel: string +}): Promise { + const { response } = await dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', options.confirmLabel], + defaultId: 0, + cancelId: 0, + title: options.title, + message: options.message, + detail: options.detail, + }) + if (response === 0) throw new Error(`${options.title} cancelled by user`) +} + export function registerVaultHandlers() { ipcMain.handle('vault:list', ipcHandler(async () => { return Vault.listFiles() @@ -22,16 +40,38 @@ export function registerVaultHandlers() { if (Buffer.byteLength(opts.data, 'utf8') > MAX_FILE_SIZE) throw new Error('File too large (max 1 MB)') const validTypes = ['keypair', 'env', 'credential', 'seed_phrase', 'other'] const fileType = validTypes.includes(opts.fileType) ? opts.fileType : 'other' + await confirmVaultAction({ + title: 'Store Vault Secret', + message: `Store "${opts.name}" in the vault?`, + detail: `Type: ${fileType}\nThis will persist encrypted secret material on this machine.`, + confirmLabel: 'Store Secret', + }) return Vault.storeFile({ name: opts.name, data: opts.data, fileType, ownerWallet: opts.ownerWallet }) })) ipcMain.handle('vault:retrieve', ipcHandler(async (_event, id: string) => { if (!id || typeof id !== 'string') throw new Error('Invalid vault file id') + const meta = Vault.getFileMeta(id) + if (!meta) throw new Error('Vault file not found') + await confirmVaultAction({ + title: 'Reveal Vault Secret', + message: `Reveal "${meta.name}" from the vault?`, + detail: `Type: ${meta.file_type}\nThis will decrypt and return the secret to the renderer.`, + confirmLabel: 'Reveal Secret', + }) return Vault.retrieveFile(id) })) ipcMain.handle('vault:delete', ipcHandler(async (_event, id: string) => { if (!id || typeof id !== 'string') throw new Error('Invalid vault file id') + const meta = Vault.getFileMeta(id) + if (!meta) throw new Error('Vault file not found') + await confirmVaultAction({ + title: 'Delete Vault Secret', + message: `Delete "${meta.name}" from the vault?`, + detail: 'This removes the encrypted record from local storage and cannot be undone.', + confirmLabel: 'Delete Secret', + }) Vault.deleteFile(id) })) diff --git a/electron/ipc/wallet.ts b/electron/ipc/wallet.ts index 73a6cb9d..d2f18757 100644 --- a/electron/ipc/wallet.ts +++ b/electron/ipc/wallet.ts @@ -5,6 +5,24 @@ import { ipcHandler } from '../services/IpcHandlerFactory' import { ValidationService } from '../services/ValidationService' import type { WalletCreateInput, WalletGenerateInput, TransferSOLInput, TransferTokenInput } from '../shared/types' +async function confirmSensitiveAction(options: { + title: string + message: string + detail: string + confirmLabel: string +}): Promise { + const { response } = await dialog.showMessageBox({ + type: 'warning', + buttons: ['Cancel', options.confirmLabel], + defaultId: 0, + cancelId: 0, + title: options.title, + message: options.message, + detail: options.detail, + }) + if (response === 0) throw new Error(`${options.title} cancelled by user`) +} + export function registerWalletHandlers() { ipcMain.handle('wallet:dashboard', ipcHandler(async (_event, projectId?: string | null) => { return await WalletService.getDashboard(projectId) @@ -54,10 +72,22 @@ export function registerWalletHandlers() { })) ipcMain.handle('wallet:send-sol', ipcHandler(async (_event, input: TransferSOLInput) => { + await confirmSensitiveAction({ + title: 'Send SOL', + message: `Send ${input.amountSol} SOL to ${input.toAddress}?`, + detail: 'This action signs and broadcasts a real transaction from the selected wallet.', + confirmLabel: 'Send SOL', + }) return await WalletService.transferSOL(input.fromWalletId, input.toAddress, input.amountSol) })) ipcMain.handle('wallet:send-token', ipcHandler(async (_event, input: TransferTokenInput) => { + await confirmSensitiveAction({ + title: 'Send Token', + message: `Send ${input.amount} tokens to ${input.toAddress}?`, + detail: `Mint: ${input.mint}\nThis action signs and broadcasts a real token transfer.`, + confirmLabel: 'Send Token', + }) return await WalletService.transferToken(input.fromWalletId, input.toAddress, input.mint, input.amount) })) @@ -131,16 +161,12 @@ export function registerWalletHandlers() { throw new Error('Too many export attempts. Please wait 5 minutes.') } - const { response } = await dialog.showMessageBox({ - type: 'warning', - buttons: ['Cancel', 'Export Key'], - defaultId: 0, - cancelId: 0, + await confirmSensitiveAction({ title: 'Export Private Key', message: 'This will expose your private key in plaintext.', detail: 'Only proceed if you understand the security implications.', + confirmLabel: 'Export Key', }) - if (response === 0) throw new Error('Export cancelled by user') const keyString = await WalletService.exportPrivateKey(walletId) clipboard.writeText(keyString) diff --git a/electron/main/index.ts b/electron/main/index.ts index 835d2a59..05d5c1a5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -196,7 +196,7 @@ async function createWindow() { callback({ responseHeaders: { ...details.responseHeaders, - 'Content-Security-Policy': ["default-src 'self' minipaint:; script-src 'self' minipaint: 'unsafe-eval'; style-src 'self' 'unsafe-inline' minipaint:; img-src 'self' data: daemon-icon: minipaint:; worker-src 'self' blob: monaco-editor: minipaint:; connect-src 'self' https://*.anthropic.com https://*.helius-rpc.com https://price.jup.ag https://api.coingecko.com; font-src 'self' minipaint:; frame-src minipaint:; object-src 'none'"] + 'Content-Security-Policy': ["default-src 'self' minipaint:; script-src 'self' minipaint:; style-src 'self' 'unsafe-inline' minipaint:; img-src 'self' data: daemon-icon: minipaint:; worker-src 'self' blob: monaco-editor: minipaint:; connect-src 'self' https://*.anthropic.com https://*.helius-rpc.com https://price.jup.ag https://api.coingecko.com; font-src 'self' minipaint:; frame-src minipaint:; object-src 'none'"] } }) }) diff --git a/electron/services/BrowserService.ts b/electron/services/BrowserService.ts index 317fe75f..865f0ece 100644 --- a/electron/services/BrowserService.ts +++ b/electron/services/BrowserService.ts @@ -1,3 +1,4 @@ +import { isIP } from 'node:net' import { pluginPrompt, orchestratedPrompt } from './PluginPrompt' import type { BrowserPage, BrowserNavResult, BrowserAnalysis } from '../shared/types' @@ -13,13 +14,87 @@ function nextPageId(): string { // --- URL Safety --- -function isCloudMetadataUrl(urlStr: string): boolean { +function isIpv4InCidr(ip: string, network: string, prefixLength: number): boolean { + const octets = ip.split('.').map(Number) + const networkOctets = network.split('.').map(Number) + if (octets.length !== 4 || networkOctets.length !== 4 || octets.some(Number.isNaN) || networkOctets.some(Number.isNaN)) { + return false + } + + let ipValue = 0 + let networkValue = 0 + for (let i = 0; i < 4; i++) { + ipValue = (ipValue << 8) + octets[i] + networkValue = (networkValue << 8) + networkOctets[i] + } + + const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0 + return (ipValue & mask) === (networkValue & mask) +} + +const BLOCKED_IPV4_RANGES: Array<{ network: string; prefixLength: number }> = [ + { network: '0.0.0.0', prefixLength: 8 }, + { network: '10.0.0.0', prefixLength: 8 }, + { network: '127.0.0.0', prefixLength: 8 }, + { network: '169.254.0.0', prefixLength: 16 }, + { network: '172.16.0.0', prefixLength: 12 }, + { network: '192.168.0.0', prefixLength: 16 }, + { network: '100.64.0.0', prefixLength: 10 }, + { network: '198.18.0.0', prefixLength: 15 }, + { network: '224.0.0.0', prefixLength: 4 }, + { network: '240.0.0.0', prefixLength: 4 }, + { network: '168.63.129.16', prefixLength: 32 }, +] + +export function isBlockedBrowserHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase() + if (!normalized) return true + + if ( + normalized === 'localhost' + || normalized.endsWith('.localhost') + || normalized === 'metadata.google.internal' + || normalized === 'metadata.azure.internal' + || normalized === 'kubernetes' + || normalized.endsWith('.local') + || normalized.endsWith('.internal') + || normalized.endsWith('.localdomain') + || normalized.endsWith('.home.arpa') + || normalized.endsWith('.localdomain') + || normalized.endsWith('.cluster.local') + ) { + return true + } + + const ipType = isIP(normalized) + if (ipType === 4) { + return BLOCKED_IPV4_RANGES.some(({ network, prefixLength }) => isIpv4InCidr(normalized, network, prefixLength)) + } + + if (ipType === 6) { + return normalized === '::1' + || normalized === '::' + || normalized.startsWith('fe80:') + || normalized.startsWith('fc') + || normalized.startsWith('fd') + || normalized.startsWith('ff') + || normalized === '::ffff:127.0.0.1' + || normalized.startsWith('::ffff:10.') + || normalized.startsWith('::ffff:192.168.') + || /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) + || normalized === '::ffff:169.254.169.254' + || normalized === '::ffff:168.63.129.16' + } + + return false +} + +export function isBlockedBrowserUrl(urlStr: string): boolean { try { const parsed = new URL(urlStr) - const hostname = parsed.hostname - // Block cloud metadata endpoints only — real SSRF vectors - if (hostname === '169.254.169.254' || hostname === 'metadata.google.internal') return true - return false + if (!['http:', 'https:'].includes(parsed.protocol)) return true + if (parsed.username || parsed.password) return true + return isBlockedBrowserHost(parsed.hostname) } catch { return true } } @@ -31,8 +106,8 @@ export async function navigate(url: string): Promise { url = `https://${url}` } - if (isCloudMetadataUrl(url)) { - throw new Error('Navigation to cloud metadata endpoints is blocked') + if (isBlockedBrowserUrl(url)) { + throw new Error('Navigation to private, local, or metadata endpoints is blocked') } // Create a page entry with the URL — actual content comes from webview via capturePageContent diff --git a/electron/services/ValidationService.ts b/electron/services/ValidationService.ts index 334e512c..d60b07b2 100644 --- a/electron/services/ValidationService.ts +++ b/electron/services/ValidationService.ts @@ -23,6 +23,8 @@ interface ValidationRule { custom?: (value: any) => boolean; } +const SIMPLE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + class ValidationServiceImpl { private pathWhitelist: Set = new Set(); private rateLimitMap: Map = new Map(); @@ -64,6 +66,55 @@ class ValidationServiceImpl { return { success: true, data: value }; } + validateEmailAddress(value: unknown): ValidationResult { + if (typeof value !== 'string') { + return { success: false, errors: ['Expected string'] }; + } + + const normalized = value.trim(); + if (!normalized) { + return { success: false, errors: ['Email address is required'] }; + } + + if (!SIMPLE_EMAIL_PATTERN.test(normalized)) { + return { success: false, errors: [`Invalid email address: ${normalized}`] }; + } + + return { success: true, data: normalized }; + } + + validateEmailList(value: unknown, required: boolean = false): ValidationResult { + if (value === undefined || value === null || value === '') { + return required + ? { success: false, errors: ['Email address is required'] } + : { success: true, data: undefined }; + } + + if (typeof value !== 'string') { + return { success: false, errors: ['Expected string'] }; + } + + const entries = value + .split(/[;,]/) + .map((entry) => entry.trim()) + .filter(Boolean); + + if (entries.length === 0) { + return required + ? { success: false, errors: ['Email address is required'] } + : { success: true, data: undefined }; + } + + const normalized: string[] = []; + for (const entry of entries) { + const result = this.validateEmailAddress(entry); + if (!result.success) return { success: false, errors: result.errors }; + normalized.push(result.data!); + } + + return { success: true, data: normalized.join(', ') }; + } + /** * Validate number input */ diff --git a/package.json b/package.json index fd10e05d..c5dac842 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daemon", - "version": "2.0.3", + "version": "2.0.4", "main": "dist-electron/main/index.js", "description": "Custom Electron IDE for AI-native development", "author": "nullxnothing", diff --git a/test/services/BrowserService.test.ts b/test/services/BrowserService.test.ts new file mode 100644 index 00000000..63d753aa --- /dev/null +++ b/test/services/BrowserService.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { isBlockedBrowserHost, isBlockedBrowserUrl, navigate } from '../../electron/services/BrowserService' + +describe('BrowserService SSRF guard', () => { + it('blocks private, loopback, metadata, and cluster-local hosts', () => { + for (const host of [ + 'localhost', + 'app.localhost', + '127.0.0.1', + '10.0.0.42', + '172.16.8.9', + '192.168.1.10', + '169.254.169.254', + '168.63.129.16', + '::1', + 'fe80::1', + 'fd00::1', + 'metadata.google.internal', + 'metadata.azure.internal', + 'kubernetes.default.svc.cluster.local', + ]) { + expect(isBlockedBrowserHost(host)).toBe(true) + } + }) + + it('allows normal public hosts', () => { + for (const host of ['example.com', 'solana.com', 'api.github.com', '1.1.1.1']) { + expect(isBlockedBrowserHost(host)).toBe(false) + } + }) + + it('rejects unsafe URLs and accepts public https URLs', () => { + expect(isBlockedBrowserUrl('file:///etc/passwd')).toBe(true) + expect(isBlockedBrowserUrl('http://localhost:3000')).toBe(true) + expect(isBlockedBrowserUrl('https://user:pass@example.com')).toBe(true) + expect(isBlockedBrowserUrl('https://example.com')).toBe(false) + }) + + it('throws on blocked navigation targets', async () => { + await expect(navigate('http://127.0.0.1:8899')).rejects.toThrow( + 'Navigation to private, local, or metadata endpoints is blocked' + ) + }) +}) diff --git a/test/services/ValidationService.test.ts b/test/services/ValidationService.test.ts index 6d319372..ccd7a269 100644 --- a/test/services/ValidationService.test.ts +++ b/test/services/ValidationService.test.ts @@ -46,6 +46,34 @@ describe('validateString', () => { }) }) +describe('validateEmailAddress', () => { + it('accepts a simple valid email address', () => { + const result = ValidationService.validateEmailAddress('alice@example.com') + expect(result.success).toBe(true) + expect(result.data).toBe('alice@example.com') + }) + + it('rejects malformed email addresses', () => { + const result = ValidationService.validateEmailAddress('not-an-email') + expect(result.success).toBe(false) + expect(result.errors![0]).toMatch(/invalid email/i) + }) +}) + +describe('validateEmailList', () => { + it('normalizes comma and semicolon separated recipient lists', () => { + const result = ValidationService.validateEmailList('alice@example.com; bob@example.com, carol@example.com', true) + expect(result.success).toBe(true) + expect(result.data).toBe('alice@example.com, bob@example.com, carol@example.com') + }) + + it('rejects invalid recipients in the list', () => { + const result = ValidationService.validateEmailList('alice@example.com, nope', true) + expect(result.success).toBe(false) + expect(result.errors![0]).toMatch(/invalid email/i) + }) +}) + describe('validateNumber', () => { it('passes a valid number', () => { const result = ValidationService.validateNumber(42, 0, 100)