fix(*): harden local service boundaries (#2062)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user