From 35cd5d7eec43f74084a83fbd498d64121bf637db Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Tue, 7 Apr 2026 19:54:35 -0600 Subject: [PATCH 1/3] build: add mac signing and notarization scaffolding --- README.md | 4 ++-- build/README.md | 26 ++++++++++++++++++++------ build/notarize.mjs | 42 ++++++++++++++++++++++++++++++++++++++++++ codemagic.yaml | 5 +++++ electron-builder.json | 5 +++-- package.json | 1 + pnpm-lock.yaml | 3 +++ 7 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 build/notarize.mjs diff --git a/README.md b/README.md index ca7ceb1c..163bbf5e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ DAEMON is a standalone Electron IDE designed around AI agent workflows. It ships -**Mac:** Build from source (signed builds coming soon): +**Mac:** Build from source (signed builds configurable via Apple credentials): ```bash git clone https://github.com/nullxnothing/daemon.git @@ -52,7 +52,7 @@ pnpm run build pnpm run package ``` -The `.dmg` will be in `release/2.0.0/`. Drag to Applications. On first launch, right-click > Open to bypass Gatekeeper (not yet signed/notarized). +The `.dmg` will be in `release/2.0.0/`. Signed/notarized builds require Apple Developer credentials in the packaging environment. Without them, the app will still package, but Gatekeeper may require right-click > Open on first launch. diff --git a/build/README.md b/build/README.md index 6a5aed2c..a5094c28 100644 --- a/build/README.md +++ b/build/README.md @@ -1,13 +1,27 @@ # Build Configuration -## Code Signing (Optional) +## Code Signing ### Windows -Set `CSC_LINK` to the path/base64 of your .pfx certificate and `CSC_KEY_PASSWORD` to the password. +Set `CSC_LINK` to the path/base64 of your `.pfx` certificate and `CSC_KEY_PASSWORD` to the password. ### macOS -Set `CSC_LINK` to the path/base64 of your .p12 certificate, `CSC_KEY_PASSWORD`, and for notarization: `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. +DAEMON now supports env-gated signing and notarization during packaging. -### GitHub Actions -Add these as repository secrets. The release workflow will use them automatically. -Without signing secrets, builds are unsigned (fine for development). +Required for signed mac builds: +- `CSC_LINK`: path or base64 content for your `.p12` Developer ID Application certificate +- `CSC_KEY_PASSWORD`: password for the `.p12` + +Required for notarization: +- `APPLE_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` +- `APPLE_TEAM_ID` + +Behavior: +- if the Apple env vars are missing, mac builds still package, but notarization is skipped +- if the certificate env vars are missing, mac builds remain unsigned +- no secrets are stored in the repo + +### CI +Add the signing and notarization secrets to your CI environment before publishing mac releases. +Without signing secrets, builds are still suitable for development but will not be Gatekeeper-clean. diff --git a/build/notarize.mjs b/build/notarize.mjs new file mode 100644 index 00000000..82f51718 --- /dev/null +++ b/build/notarize.mjs @@ -0,0 +1,42 @@ +import { notarize } from '@electron/notarize' + +const REQUIRED_ENV_VARS = [ + 'APPLE_ID', + 'APPLE_APP_SPECIFIC_PASSWORD', + 'APPLE_TEAM_ID', +] + +function hasNotarizeEnv() { + return REQUIRED_ENV_VARS.every((key) => Boolean(process.env[key])) +} + +export default async function afterSign(context) { + if (process.platform !== 'darwin') { + return + } + + if (!hasNotarizeEnv()) { + console.log('[notarize] Skipping notarization; missing Apple credentials in environment') + return + } + + const { appOutDir, electronPlatformName, packager } = context + if (electronPlatformName !== 'darwin') { + return + } + + const appName = packager.appInfo.productFilename + const appBundleId = packager.appInfo.id + + console.log(`[notarize] Submitting ${appName}.app for notarization`) + + await notarize({ + appBundleId, + appPath: `${appOutDir}/${appName}.app`, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }) + + console.log('[notarize] Notarization completed') +} diff --git a/codemagic.yaml b/codemagic.yaml index 79b76e05..87c7ebc3 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -7,6 +7,11 @@ workflows: node: 22 groups: - cloudflare + # Optional: add a Codemagic group that provides + # CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, + # APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID + # when you are ready to ship signed/notarized mac builds. + # - apple-signing triggering: events: - tag diff --git a/electron-builder.json b/electron-builder.json index 16726e1b..33da3a0a 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -27,9 +27,10 @@ "target": ["dmg", "zip"], "category": "public.app-category.developer-tools", "artifactName": "${productName}-${arch}.${ext}", - "identity": null, - "notarize": false + "hardenedRuntime": true, + "gatekeeperAssess": false }, + "afterSign": "build/notarize.mjs", "win": { "target": [ { diff --git a/package.json b/package.json index f1d6de03..d2649f21 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "smol-toml": "^1.6.1" }, "devDependencies": { + "@electron/notarize": "^2.2.1", "@electron/rebuild": "^3.7.1", "@monaco-editor/react": "^4.7.0", "@types/better-sqlite3": "^7.6.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 214499da..dc31f879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: specifier: ^1.6.1 version: 1.6.1 devDependencies: + '@electron/notarize': + specifier: ^2.2.1 + version: 2.2.1 '@electron/rebuild': specifier: ^3.7.1 version: 3.7.2 From 7e10e402d455e994117750d69316e0771fdd977f Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Tue, 7 Apr 2026 22:24:19 -0600 Subject: [PATCH 2/3] release: build intel and arm mac artifacts --- .github/workflows/release.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee20b6f7..bf994ddb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,7 @@ jobs: echo "Notarization secrets found, enabling notarize" npx json -I -f electron-builder.json -e 'this.mac.notarize=true' fi - pnpm exec electron-builder --publish never + pnpm exec electron-builder --mac dmg zip --arm64 --x64 --publish never - name: Generate checksums run: | cd release diff --git a/package.json b/package.json index d2649f21..72ff46aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daemon", - "version": "2.0.5", + "version": "2.0.6", "main": "dist-electron/main/index.js", "description": "Custom Electron IDE for AI-native development", "author": "nullxnothing", From 285c33e62c43404f68bd17a9e6cf94c1cb0444ec Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Wed, 8 Apr 2026 10:57:16 -0600 Subject: [PATCH 3/3] fix: enforce browser SSRF guard at webview boundary --- electron/main/index.ts | 53 ++++++++++---- electron/services/BrowserService.ts | 88 +++++++++++++++++++++-- src/panels/BlockScanner/BlockScanner.tsx | 26 ++++--- src/panels/BrowserMode/BrowserMode.tsx | 4 +- src/panels/BrowserMode/BrowserWebview.tsx | 20 +++--- test/services/BrowserService.test.ts | 44 ++++++++++++ 6 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 test/services/BrowserService.test.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 835d2a59..d35b3814 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -38,10 +38,11 @@ import { registerColosseumHandlers } from '../ipc/colosseum' import { registerVaultHandlers } from '../ipc/vault' import { registerValidatorHandlers } from '../ipc/validator' import { registerPnlHandlers } from '../ipc/pnl' -import { registerFeedbackHandlers } from '../ipc/feedback' -import { clearLoadedWallets } from '../services/RecoveryService' -import pkg from 'electron-updater' -const { autoUpdater } = pkg +import { registerFeedbackHandlers } from '../ipc/feedback' +import { clearLoadedWallets } from '../services/RecoveryService' +import { isBlockedBrowserUrl } from '../services/BrowserService' +import pkg from 'electron-updater' +const { autoUpdater } = pkg const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -106,10 +107,14 @@ if (!SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) { process.exit(0) } -let win: BrowserWindow | null = null -let ipcRegistered = false -const preload = path.join(__dirname, '../preload/index.mjs') -const indexHtml = path.join(RENDERER_DIST, 'index.html') +let win: BrowserWindow | null = null +let ipcRegistered = false +const preload = path.join(__dirname, '../preload/index.mjs') +const indexHtml = path.join(RENDERER_DIST, 'index.html') + +function isBlockedWebviewNavigation(url: string): boolean { + return isBlockedBrowserUrl(url) +} function registerAllIpc() { if (ipcRegistered) return @@ -301,12 +306,32 @@ async function createWindow() { }) // Enforce security on webview creation from main process - win.webContents.on('will-attach-webview', (_event, webPreferences) => { - webPreferences.nodeIntegration = false - webPreferences.contextIsolation = true - webPreferences.sandbox = true - delete (webPreferences as Record).preload - }) + win.webContents.on('will-attach-webview', (event, webPreferences, params) => { + webPreferences.nodeIntegration = false + webPreferences.contextIsolation = true + webPreferences.sandbox = true + delete (webPreferences as Record).preload + + if (params.src && isBlockedWebviewNavigation(params.src)) { + event.preventDefault() + } + }) + + win.webContents.on('did-attach-webview', (_event, contents) => { + contents.setWindowOpenHandler(({ url }) => { + if (isBlockedWebviewNavigation(url)) { + return { action: 'deny' } + } + void shell.openExternal(url) + return { action: 'deny' } + }) + + contents.on('will-navigate', (event, url) => { + if (isBlockedWebviewNavigation(url)) { + event.preventDefault() + } + }) + }) // Block navigation away from app origin (XSS defense) win.webContents.on('will-navigate', (event, url) => { diff --git a/electron/services/BrowserService.ts b/electron/services/BrowserService.ts index 317fe75f..d94a9efb 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,86 @@ 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('.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 +105,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/src/panels/BlockScanner/BlockScanner.tsx b/src/panels/BlockScanner/BlockScanner.tsx index 08701753..155e1f6e 100644 --- a/src/panels/BlockScanner/BlockScanner.tsx +++ b/src/panels/BlockScanner/BlockScanner.tsx @@ -41,24 +41,30 @@ export default function BlockScanner() { const [canGoForward, setCanGoForward] = useState(false) const webviewRef = useRef(null) - const navigate = useCallback((target: string) => { - setUrl(target) - if (webviewRef.current) { - webviewRef.current.src = target + const navigate = useCallback(async (target: string) => { + try { + const res = await window.daemon.browser.navigate(target) + if (!res.ok || !res.data) return + setUrl(target) + if (webviewRef.current) { + webviewRef.current.src = target + } + } catch { + // blocked by main-process browser safety policy } }, []) - const handleSearch = () => { + const handleSearch = async () => { const q = search.trim() if (!q) { - navigate(clusterBase(cluster)) + await navigate(clusterBase(cluster)) return } // Tx signatures are 87-88 base58 chars, addresses are 32-44 if (q.length > 60) { - navigate(txUrl(cluster, q)) + await navigate(txUrl(cluster, q)) } else { - navigate(addressUrl(cluster, q)) + await navigate(addressUrl(cluster, q)) } setSearch('') } @@ -96,9 +102,9 @@ export default function BlockScanner() { useEffect(() => { if (!url) { const initial = clusterBase(cluster) - setUrl(initial) + void navigate(initial) } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + }, [cluster, navigate, url]) const webviewProps = { ref: webviewRef as React.Ref, diff --git a/src/panels/BrowserMode/BrowserMode.tsx b/src/panels/BrowserMode/BrowserMode.tsx index 115af12b..c8d6b3ec 100644 --- a/src/panels/BrowserMode/BrowserMode.tsx +++ b/src/panels/BrowserMode/BrowserMode.tsx @@ -17,15 +17,13 @@ export function BrowserMode() { const loadStatus = useBrowserStore((s) => s.loadStatus) const canGoBack = useBrowserStore((s) => s.canGoBack) const canGoForward = useBrowserStore((s) => s.canGoForward) - const setUrl = useBrowserStore((s) => s.setUrl) const setInspectMode = useBrowserStore((s) => s.setInspectMode) const handleNavigate = useCallback( (url: string) => { - setUrl(url) webviewRef.current?.navigate(url) }, - [setUrl] + [] ) const handleBack = useCallback(() => { diff --git a/src/panels/BrowserMode/BrowserWebview.tsx b/src/panels/BrowserMode/BrowserWebview.tsx index e7d5acec..bd91fd9b 100644 --- a/src/panels/BrowserMode/BrowserWebview.tsx +++ b/src/panels/BrowserMode/BrowserWebview.tsx @@ -57,22 +57,24 @@ export const BrowserWebview = forwardRef(function BrowserW async (url: string) => { const normalized = normalizeUrl(url) if (!normalized) return - setUrl(normalized) setLoadStatus('loading') - isNavigated.current = true // Create page cache entry in main process and store the pageId try { const res = await window.daemon.browser.navigate(normalized) - if (res.ok && res.data) { - useBrowserStore.getState().setLastPageId(res.data.pageId) + if (!res.ok || !res.data) { + setLoadStatus('error') + return } - } catch { - // Cache entry creation failed — capture will use fallback ID - } - if (webviewRef.current) { - webviewRef.current.src = normalized + useBrowserStore.getState().setLastPageId(res.data.pageId) + setUrl(normalized) + isNavigated.current = true + if (webviewRef.current) { + webviewRef.current.src = normalized + } + } catch { + setLoadStatus('error') } }, [normalizeUrl, setUrl, setLoadStatus] 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' + ) + }) +})