fix(*): harden local service boundaries (#2062)

This commit is contained in:
0xSelenicDove
2026-07-17 17:50:50 +08:00
committed by GitHub
parent f453a967f1
commit 8893ba81a6
31 changed files with 248 additions and 124 deletions
@@ -33,12 +33,25 @@ jobs:
- name: Parse metadata
id: meta
run: |
source preview-meta/preview_meta
echo "PR_NUM=$PR_NUM" >> "$GITHUB_OUTPUT"
echo "REPO_FULL_NAME=$REPO_FULL_NAME" >> "$GITHUB_OUTPUT"
echo "HEAD_REF=$HEAD_REF" >> "$GITHUB_OUTPUT"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "BRANCH_NAME=$HEAD_REF" >> "$GITHUB_ENV"
node --input-type=module <<'NODE'
import { appendFile, readFile } from 'node:fs/promises'
const metadata = JSON.parse(await readFile('preview-meta/preview-meta.json', 'utf8'))
if (
!/^\d+$/.test(metadata.prNumber)
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(metadata.repositoryFullName)
|| !/^[a-f0-9]{40}$/i.test(metadata.headSha)
) {
throw new Error('Preview metadata has an invalid shape')
}
await appendFile(process.env.GITHUB_OUTPUT, [
`PR_NUM=${metadata.prNumber}`,
`REPO_FULL_NAME=${metadata.repositoryFullName}`,
`HEAD_SHA=${metadata.headSha}`,
'',
].join('\n'))
NODE
- name: Find Comment
if: ${{ always() }}
@@ -103,12 +116,25 @@ jobs:
- name: Parse metadata
id: meta
run: |
source preview-meta/preview_meta
echo "PR_NUM=$PR_NUM" >> "$GITHUB_OUTPUT"
echo "REPO_FULL_NAME=$REPO_FULL_NAME" >> "$GITHUB_OUTPUT"
echo "HEAD_REF=$HEAD_REF" >> "$GITHUB_OUTPUT"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "BRANCH_NAME=$HEAD_REF" >> "$GITHUB_ENV"
node --input-type=module <<'NODE'
import { appendFile, readFile } from 'node:fs/promises'
const metadata = JSON.parse(await readFile('preview-meta/preview-meta.json', 'utf8'))
if (
!/^\d+$/.test(metadata.prNumber)
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(metadata.repositoryFullName)
|| !/^[a-f0-9]{40}$/i.test(metadata.headSha)
) {
throw new Error('Preview metadata has an invalid shape')
}
await appendFile(process.env.GITHUB_OUTPUT, [
`PR_NUM=${metadata.prNumber}`,
`REPO_FULL_NAME=${metadata.repositoryFullName}`,
`HEAD_SHA=${metadata.headSha}`,
'',
].join('\n'))
NODE
- name: Checkout repository
uses: actions/checkout@v6
@@ -14,17 +14,24 @@ jobs:
steps:
- name: Persist checkout metadata
env:
PR_NUM: ${{ github.event.number }}
REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
cat <<EOF > preview_meta
PR_NUM=${{ github.event.number }}
REPO_FULL_NAME=${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF=${{ github.event.pull_request.head.ref }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
EOF
node --input-type=module <<'NODE'
import { writeFile } from 'node:fs/promises'
await writeFile('preview-meta.json', JSON.stringify({
prNumber: process.env.PR_NUM,
repositoryFullName: process.env.REPO_FULL_NAME,
headSha: process.env.HEAD_SHA,
}))
NODE
- name: Upload metadata artifact
uses: actions/upload-artifact@v7
with:
name: preview-meta
path: ./preview_meta
path: ./preview-meta.json
overwrite: true
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
# failed to build archive at `/home/runner/work/airi/airi/target/x86_64-unknown-linux-gnu/release/deps/libapp_lib.rlib`:
# No space left on device (os error 28)
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
- uses: actions/checkout@v6
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
# No space left on device (os error 28)
- name: Free Disk Space
if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm'
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
- uses: actions/checkout@v6
@@ -25,7 +25,7 @@ jobs:
# > airi-pnpm-deps> Running phase: fixupPhase
# > error: writing to file: No space left on device
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
- uses: actions/checkout@v6
with:
@@ -24,7 +24,7 @@ jobs:
# > airi-pnpm-deps> Running phase: fixupPhase
# > error: writing to file: No space left on device
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
- uses: actions/checkout@v6
with:
+4
View File
@@ -2,6 +2,8 @@ FROM node:24-alpine
WORKDIR /app
RUN addgroup -S airi && adduser -S airi -G airi
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
@@ -37,4 +39,6 @@ RUN pnpm -F @proj-airi/server run build
EXPOSE 3000
USER airi
CMD ["pnpm", "-F", "@proj-airi/server", "start"]
@@ -2,6 +2,8 @@ FROM node:24-alpine
WORKDIR /app
RUN addgroup -S airi && adduser -S airi -G airi
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
@@ -36,4 +38,6 @@ RUN pnpm -F @proj-airi/server run build
EXPOSE 3000
USER airi
CMD ["pnpm", "-F", "@proj-airi/server", "start"]
+77 -4
View File
@@ -3,6 +3,8 @@ import type { Context } from 'hono'
import type { RateLimitMetrics } from '../otel'
import type { HonoEnv } from '../types/hono'
import { isIP } from 'node:net'
import { getConnInfo } from '@hono/node-server/conninfo'
import { rateLimiter as createRateLimiter } from 'hono-rate-limiter'
@@ -13,6 +15,12 @@ interface RateLimitOptions {
windowSec: number
/** Key generator: extracts a unique identifier from the request */
keyGenerator?: (c: Context<HonoEnv>) => string
/**
* Reverse proxy whose client-address header is safe to use. The caller must
* select this only for a deployment that prevents direct public access to
* the application process.
*/
trustedProxy?: 'railway'
/**
* Optional metrics handle. When provided, blocked requests increment
* `airi_rate_limit_blocked_total{route, key_type, limit}`.
@@ -40,10 +48,20 @@ export function rateLimiter(opts: RateLimitOptions) {
if (userId)
return userId
// NOTICE: prefer hono conninfo (uses underlying socket address) over
// x-forwarded-for which can be spoofed. Falls back to header then 'anonymous'.
const info = getConnInfo(c)
return info.remote?.address ?? c.req.header('x-forwarded-for') ?? 'anonymous'
const trustedProxyAddress = getTrustedProxyClientAddress(c, opts.trustedProxy)
if (trustedProxyAddress)
return trustedProxyAddress
// `app.request()` and fetch-style deployments have no Node incoming
// socket. Keep those requests in a shared bucket rather than trusting a
// client-controlled forwarding header.
try {
const info = getConnInfo(c)
return info.remote?.address ?? 'anonymous'
}
catch {
return 'anonymous'
}
})
return createRateLimiter<HonoEnv>({
@@ -67,3 +85,58 @@ export function rateLimiter(opts: RateLimitOptions) {
},
})
}
/**
* Returns Railway's canonical client address only for a request received from
* its internal proxy network.
*
* Before:
* - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket
*
* After:
* - `X-Real-IP` is used only when Railway's edge marker and an internal socket
* prove the request traversed the configured Railway proxy boundary
*/
function getTrustedProxyClientAddress(c: Context<HonoEnv>, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined {
if (trustedProxy !== 'railway')
return undefined
try {
const remoteAddress = getConnInfo(c).remote?.address
const edge = c.req.header('x-railway-edge')
const clientAddress = c.req.header('x-real-ip')?.trim()
if (!isRailwayInternalAddress(remoteAddress) || !edge?.startsWith('railway/') || !clientAddress || isIP(clientAddress) === 0)
return undefined
return clientAddress
}
catch {
return undefined
}
}
/**
* Identifies address ranges Railway documents for internal proxy traffic.
*
* Before:
* - `203.0.113.42`
*
* After:
* - `100.64.0.42`
*/
function isRailwayInternalAddress(address: string | undefined): boolean {
if (!address)
return false
const normalizedAddress = address.replace(/^::ffff:/i, '')
const octets = normalizedAddress.split('.').map(Number)
if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255))
return false
const [first, second] = octets
return first === 10
|| first === 100
|| first === 127
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168)
}
+12 -1
View File
@@ -17,6 +17,15 @@ import { createElectronCallbackRelay } from './oidc/electron-callback'
import { createOIDCTokenAuthRoute } from './oidc/token-auth'
import { createAuthUiRoutes } from './ui-routes'
function usesRailwayEdge(apiServerUrl: string): boolean {
try {
return new URL(apiServerUrl).hostname.endsWith('.up.railway.app')
}
catch {
return false
}
}
export interface AuthRoutesDeps {
auth: AuthInstance
db: Database
@@ -53,7 +62,9 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
.use('/api/auth/*', rateLimiter({
max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'),
windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'),
keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown',
// Railway documents `X-Real-IP` as the client address. Limit trust to
// its deployed domain; self-hosted instances keep socket-only buckets.
trustedProxy: usesRailwayEdge(deps.env.API_SERVER_URL) ? 'railway' : undefined,
metrics: deps.rateLimitMetrics,
routeLabel: 'auth.api',
}))
@@ -4,12 +4,13 @@ import type { AutoUpdater } from '../../services/electron/auto-updater'
import { join, resolve } from 'node:path'
import { BrowserWindow, shell } from 'electron'
import { BrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { protectPrivilegedWindowNavigation } from '../shared'
import { setupAboutWindowElectronInvokes } from './rpc/index.electron'
export function setupAboutWindowReusable(params: {
@@ -34,10 +35,7 @@ export function setupAboutWindowReusable(params: {
})
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await setupAboutWindowElectronInvokes({
window,
@@ -5,6 +5,7 @@ import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/m
import { BrowserWindow } from 'electron'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
import { protectPrivilegedWindowNavigation } from '../shared/window'
export async function setupBeatSync() {
const window = new BrowserWindow({
@@ -15,6 +16,8 @@ export async function setupBeatSync() {
},
})
protectPrivilegedWindowNavigation(window)
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html'))
initScreenCaptureForWindow(window)
@@ -10,7 +10,7 @@ import { join, resolve } from 'node:path'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { animate, utils } from 'animejs'
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron'
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen } from 'electron'
import { debounce, throttle } from 'es-toolkit'
import { isMacOS } from 'std-env'
import { boolean, number, object, optional, record, string } from 'valibot'
@@ -22,7 +22,7 @@ import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs
import { createConfig } from '../../libs/electron/persistence'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display'
import { setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
const captionConfigSchema = object({
isFollowing: boolean(),
@@ -137,10 +137,7 @@ function createCaptionWindow(options?: BrowserWindowConstructorOptions) {
}
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
return window
}
@@ -5,12 +5,13 @@ import type { WidgetsWindowManager } from '../widgets'
import { join, resolve } from 'node:path'
import { BrowserWindow, shell } from 'electron'
import { BrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { protectPrivilegedWindowNavigation } from '../shared'
import { setupChatWindowElectronInvokes } from './rpc/index.electron'
export function setupChatWindowReusableFunc(params: {
@@ -33,10 +34,7 @@ export function setupChatWindowReusableFunc(params: {
})
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await setupChatWindowElectronInvokes({
window,
@@ -17,7 +17,7 @@ import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main'
import { defu } from 'defu'
import { BrowserWindow, ipcMain, shell } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import { isLinux } from 'std-env'
import { array, number, object, optional, string } from 'valibot'
@@ -26,6 +26,7 @@ import icon from '../../../../resources/icon.png?asset'
import { electronStartDraggingWindow } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { protectPrivilegedWindowNavigation } from '../shared'
import { setupDashboardWindowElectronInvokes } from './rpc/index.electron'
const appConfigSchema = object({
@@ -127,10 +128,7 @@ export async function setupDashboardWindow(params: {
window.on('move', () => handleNewBounds(window.getBounds()))
window.on('ready-to-show', () => window!.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await setupDashboardWindowElectronInvokes({
window,
@@ -30,6 +30,7 @@ import { BrowserWindow, screen } from 'electron'
import { desktopOverlayPollHeartbeatMarker, desktopOverlayPollHeartbeatQueryParam } from '../../../shared/desktop-overlay-heartbeat'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { protectPrivilegedWindowNavigation } from '../shared/window'
import { setupDesktopOverlayElectronInvokes } from './rpc/index.electron'
import {
applyDesktopOverlayInputIsolation,
@@ -81,6 +82,7 @@ export async function setupDesktopOverlayWindow(params: {
bounds: primaryDisplay.bounds,
preloadPath,
}))
protectPrivilegedWindowNavigation(overlayWindow)
applyDesktopOverlayInputIsolation(overlayWindow)
overlayWindow.on('ready-to-show', () => {
@@ -1,11 +1,12 @@
import { join, resolve } from 'node:path'
import { BrowserWindow, shell } from 'electron'
import { BrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { protectPrivilegedWindowNavigation } from '../shared'
export interface OpenDevtoolsWindowParams extends Partial<Electron.Rectangle> {
key: string
@@ -47,10 +48,7 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager {
if (reusableWindows.get(key) === reusable)
reusableWindows.delete(key)
})
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await load(window, withHashRoute(rendererBase, route))
return window
@@ -3,14 +3,14 @@ import type { ServerChannel } from '../../services/airi/channel-server'
import { join, resolve } from 'node:path'
import { BrowserWindow, shell } from 'electron'
import { BrowserWindow } from 'electron'
import { isMacOS } from 'std-env'
import icon from '../../../../resources/icon.png?asset'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { currentDisplayBounds, mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display'
import { spotlightLikeWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, spotlightLikeWindowConfig } from '../shared/window'
import { setupInlayWindowInvokes } from './rpc/index.electron'
export async function setupInlayWindow(params: {
@@ -62,10 +62,7 @@ export async function setupInlayWindow(params: {
})
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n })
@@ -23,7 +23,7 @@ import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main'
import { defu } from 'defu'
import { BrowserWindow, ipcMain, shell } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import { isLinux, isMacOS } from 'std-env'
import { array, number, object, optional, string } from 'valibot'
@@ -33,7 +33,7 @@ import { electronStartDraggingWindow } from '../../../shared/eventa'
import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { transparentWindowConfig } from '../shared'
import { protectPrivilegedWindowNavigation, transparentWindowConfig } from '../shared'
import { setupMainWindowElectronInvokes } from './rpc/index.electron'
const appConfigSchema = object({
@@ -171,10 +171,7 @@ export async function setupMainWindow(params: {
}
window.on('ready-to-show', () => window!.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
await setupMainWindowElectronInvokes({
window,
@@ -8,13 +8,14 @@ import { join, resolve } from 'node:path'
import { defineInvokeHandler } from '@moeru/eventa'
import { safeClose } from '@proj-airi/electron-vueuse/main'
import { BrowserWindow as ElectronBrowserWindow, shell } from 'electron'
import { BrowserWindow as ElectronBrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { noticeWindowEventa } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReferencedWindowManager } from '../shared/referenced-window'
import { protectPrivilegedWindowNavigation } from '../shared/window'
export interface NoticeWindowManager {
open: (payload: RequestWindowPayload) => Promise<boolean>
@@ -39,10 +40,7 @@ export function setupNoticeWindowManager(params: {
},
})
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
return window
}
@@ -7,7 +7,7 @@ import { join, resolve } from 'node:path'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { safeClose } from '@proj-airi/electron-vueuse/main'
import { BrowserWindow, ipcMain, shell } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import { isMacOS } from 'std-env'
import icon from '../../../../resources/icon.png?asset'
@@ -16,7 +16,7 @@ import { electronOnboardingClose } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { createAuthService } from '../../services/airi/auth'
import { toggleWindowShow } from '../shared'
import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared'
import { setupBaseWindowElectronInvokes } from '../shared/window'
export interface OnboardingWindowManager {
@@ -60,10 +60,7 @@ export function setupOnboardingWindowManager(params: {
})
newWindow.on('ready-to-show', () => newWindow.show())
newWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(newWindow)
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
@@ -12,14 +12,14 @@ import type { WidgetsWindowManager } from '../widgets'
import { join, resolve } from 'node:path'
import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main'
import { BrowserWindow, shell } from 'electron'
import { BrowserWindow } from 'electron'
import icon from '../../../../resources/icon.png?asset'
import { electronSettingsNavigate } from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { toggleWindowShow } from '../shared'
import { protectPrivilegedWindowNavigation, toggleWindowShow } from '../shared'
import { setupSettingsWindowInvokes } from './rpc/index.electron'
export interface SettingsWindowManager {
@@ -64,10 +64,7 @@ export function setupSettingsWindowReusableFunc(params: {
}
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
settingsContext = await setupSettingsWindowInvokes({
settingsWindow: window,
@@ -1 +1 @@
export { toggleWindowShow, transparentWindowConfig } from './window'
export { protectPrivilegedWindowNavigation, toggleWindowShow, transparentWindowConfig } from './window'
@@ -6,6 +6,7 @@ import type { I18n } from '../../libs/i18n'
import type { ServerChannel } from '../../services/airi/channel-server'
import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main'
import { shell } from 'electron'
import { isMacOS } from 'std-env'
import { createServerChannelService } from '../../services/airi/channel-server'
@@ -37,6 +38,46 @@ export function transparentWindowConfig(): BrowserWindowConstructorOptions {
}
}
/**
* Blocks renderer navigation while allowing safe links to open in the system browser.
*
* Use when:
* - Creating an Electron window that receives the shared privileged preload
*
* Expects:
* - The window loads only AIRI-controlled renderer content
*
* Returns:
* - Nothing; installs navigation and popup guards on the window's web contents
*/
export function protectPrivilegedWindowNavigation(window: BrowserWindow): void {
function openSafeExternalUrl(rawUrl: string): void {
try {
const url = new URL(rawUrl)
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:')
void shell.openExternal(url.toString())
}
catch {
// Ignore malformed navigation targets.
}
}
window.webContents.setWindowOpenHandler((details) => {
openSafeExternalUrl(details.url)
return { action: 'deny' }
})
window.webContents.on('will-navigate', (event, navigationUrl) => {
// Renderer-initiated reloads keep the exact current URL in both packaged
// (`file:`) and Vite (`http:`) builds. Let those through without widening
// navigation to other local files or development-server paths.
if (navigationUrl === window.webContents.getURL())
return
event.preventDefault()
openSafeExternalUrl(navigationUrl)
})
}
export function blurryWindowConfig(): BrowserWindowConstructorOptions {
return {
vibrancy: 'hud',
@@ -24,7 +24,7 @@ import {
import { isSafeSpotlightAccelerator } from '../../../shared/spotlight-shortcut'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
const SPOTLIGHT_WINDOW_WIDTH = 720
const SPOTLIGHT_WINDOW_HEIGHT = 100
@@ -118,6 +118,8 @@ export function setupSpotlightWindowManager(params: {
},
})
protectPrivilegedWindowNavigation(window)
window.on('blur', () => window.hide())
const { context } = createContext(ipcMain, window)
@@ -15,7 +15,7 @@ import { join, resolve } from 'node:path'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { safeClose } from '@proj-airi/electron-vueuse/main'
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron'
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen } from 'electron'
import { clamp } from 'es-toolkit/math'
import { isMacOS } from 'std-env'
import { number, object, optional } from 'valibot'
@@ -27,7 +27,7 @@ import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/window
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator'
import { setupWidgetsWindowInvokes } from './rpc/index.electron'
@@ -242,10 +242,7 @@ function createWidgetsWindow() {
window.setWindowButtonVisibility(false)
window.on('ready-to-show', () => window.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
protectPrivilegedWindowNavigation(window)
return window
}
+8 -1
View File
@@ -152,5 +152,12 @@ export function initEnv(): void {
config.airi = parsedConfig.data.airi
config.debug = parsedConfig.data.debug
logger.withFields({ config }).log('Environment variables initialized')
logger.withFields({
config: {
...config,
openai: { ...config.openai, apiKey: '[REDACTED]' },
bot: { ...config.bot, password: '[REDACTED]' },
airi: { ...config.airi, token: '[REDACTED]' },
},
}).log('Environment variables initialized')
}
@@ -254,17 +254,6 @@ export class McpReplServer {
this.server = createServer(async (req, res) => {
try {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-Id, Last-Event-ID')
res.setHeader('Access-Control-Expose-Headers', 'MCP-Session-Id')
if (req.method === 'OPTIONS') {
res.statusCode = 204
res.end()
return
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
if (req.method === 'GET' && url.pathname === '/') {
@@ -358,7 +347,7 @@ export class McpReplServer {
}
})
this.server.listen(this.port, () => {
this.server.listen(this.port, '127.0.0.1', () => {
logger.log(`MCP REPL server running at http://localhost:${this.port}`)
})
}
+1 -1
View File
@@ -103,7 +103,7 @@ export class DebugServer {
this.sendHeartbeat()
}, this.HEARTBEAT_INTERVAL)
this.httpServer.listen(port, () => {
this.httpServer.listen(port, '127.0.0.1', () => {
useLogger().log(`Debug server running at http://localhost:${port}`)
})
}
@@ -424,23 +424,6 @@ export class MCPAdapter {
private setupRoutes(): void {
const router = createRouter()
// Set up CORS
router.use('*', defineEventHandler((event) => {
const node = event.node
if (!node?.res || !node.req) {
return
}
node.res.setHeader('Access-Control-Allow-Origin', '*')
node.res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
node.res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (node.req.method === 'OPTIONS') {
node.res.statusCode = 204
node.res.end()
}
}))
// SSE endpoint
router.get('/sse', defineEventHandler(async (event) => {
const node = event.node
@@ -561,7 +544,7 @@ export class MCPAdapter {
})
}
this.server.listen(this.port, () => {
this.server.listen(this.port, '127.0.0.1', () => {
const serverAddress = `http://localhost:${this.port}`
logger.mcp.log(`MCP server started at: ${serverAddress}`)
logger.mcp.log(`SSE endpoint: ${serverAddress}/sse`)
@@ -93,7 +93,7 @@ export function getDefaultConfig(): Config {
},
mcp: {
port: Number(process.env.MCP_PORT || 8080),
enabled: process.env.ENABLE_MCP === 'true' || true,
enabled: process.env.ENABLE_MCP === 'true',
},
},
system: {