diff --git a/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml b/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml index 21dfd752b..f747a971d 100644 --- a/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml +++ b/.github/workflows/deploy-cloudflare-workers-preview-deploy.yml @@ -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 diff --git a/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml b/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml index b358b6592..9d055cd9e 100644 --- a/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml +++ b/.github/workflows/deploy-cloudflare-workers-preview-prepare.yml @@ -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 < 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 diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml index 5d4a6bcb7..18518f491 100644 --- a/.github/workflows/release-docker.yaml +++ b/.github/workflows/release-docker.yaml @@ -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 diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml index 3ff55577c..b15521d4f 100644 --- a/.github/workflows/release-tamagotchi.yml +++ b/.github/workflows/release-tamagotchi.yml @@ -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 diff --git a/.github/workflows/update-nix-assets-hash.yaml b/.github/workflows/update-nix-assets-hash.yaml index 26b2980c6..47bd4df47 100644 --- a/.github/workflows/update-nix-assets-hash.yaml +++ b/.github/workflows/update-nix-assets-hash.yaml @@ -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: diff --git a/.github/workflows/update-nix-pnpm-deps-hash.yaml b/.github/workflows/update-nix-pnpm-deps-hash.yaml index 3017db639..fc7403212 100644 --- a/.github/workflows/update-nix-pnpm-deps-hash.yaml +++ b/.github/workflows/update-nix-pnpm-deps-hash.yaml @@ -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: diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 9e4d95ad5..02cbba052 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -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"] diff --git a/apps/server/production/railway/Dockerfile b/apps/server/production/railway/Dockerfile index c92569a72..d34434908 100644 --- a/apps/server/production/railway/Dockerfile +++ b/apps/server/production/railway/Dockerfile @@ -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"] diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index 556a34ef8..dea4777b2 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -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) => 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({ @@ -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, 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) +} diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index b92e4f4f0..562f2fc61 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -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', })) diff --git a/apps/stage-tamagotchi/src/main/windows/about/index.ts b/apps/stage-tamagotchi/src/main/windows/about/index.ts index a29be7139..0306df29d 100644 --- a/apps/stage-tamagotchi/src/main/windows/about/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/about/index.ts @@ -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, diff --git a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts b/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts index 51468afa0..bbc2effed 100644 --- a/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/beat-sync/index.ts @@ -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) diff --git a/apps/stage-tamagotchi/src/main/windows/caption/index.ts b/apps/stage-tamagotchi/src/main/windows/caption/index.ts index d5e2ec11c..0b822d44c 100644 --- a/apps/stage-tamagotchi/src/main/windows/caption/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/caption/index.ts @@ -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 } diff --git a/apps/stage-tamagotchi/src/main/windows/chat/index.ts b/apps/stage-tamagotchi/src/main/windows/chat/index.ts index f6485ecd0..c3dca9ddc 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/index.ts @@ -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, diff --git a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts index b5793fcb5..56e45c353 100644 --- a/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/dashboard/index.ts @@ -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, diff --git a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts index d1a2a8317..e2c81e92c 100644 --- a/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/desktop-overlay/index.ts @@ -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', () => { diff --git a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts index 67b851cdb..ef635d6f8 100644 --- a/apps/stage-tamagotchi/src/main/windows/devtools/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/devtools/index.ts @@ -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 { 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 diff --git a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts index 430491a7b..5e5c431a7 100644 --- a/apps/stage-tamagotchi/src/main/windows/inlay/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/inlay/index.ts @@ -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 }) diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 835ec47fb..0f452fc8a 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -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, diff --git a/apps/stage-tamagotchi/src/main/windows/notice/index.ts b/apps/stage-tamagotchi/src/main/windows/notice/index.ts index 6d4106d4e..7087e4de4 100644 --- a/apps/stage-tamagotchi/src/main/windows/notice/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/notice/index.ts @@ -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 @@ -39,10 +40,7 @@ export function setupNoticeWindowManager(params: { }, }) - window.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) + protectPrivilegedWindowNavigation(window) return window } diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index e0d087fb5..b1b093a9d 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -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 diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 8a7fe5e3e..df52dc48f 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -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, diff --git a/apps/stage-tamagotchi/src/main/windows/shared/index.ts b/apps/stage-tamagotchi/src/main/windows/shared/index.ts index de1528d57..16311714e 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/index.ts @@ -1 +1 @@ -export { toggleWindowShow, transparentWindowConfig } from './window' +export { protectPrivilegedWindowNavigation, toggleWindowShow, transparentWindowConfig } from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts index 637467b4d..cf8761d3f 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/window.ts @@ -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', diff --git a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts index a6df01ee5..36e28083f 100644 --- a/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/spotlight/index.ts @@ -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) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 7a7123d29..997405c0d 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -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 } diff --git a/services/minecraft/src/composables/config.ts b/services/minecraft/src/composables/config.ts index 97062b912..c9a275803 100644 --- a/services/minecraft/src/composables/config.ts +++ b/services/minecraft/src/composables/config.ts @@ -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') } diff --git a/services/minecraft/src/debug/mcp-repl-server.ts b/services/minecraft/src/debug/mcp-repl-server.ts index 857774ce9..015cbb9b1 100644 --- a/services/minecraft/src/debug/mcp-repl-server.ts +++ b/services/minecraft/src/debug/mcp-repl-server.ts @@ -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}`) }) } diff --git a/services/minecraft/src/debug/server.ts b/services/minecraft/src/debug/server.ts index 6776ef0e1..ac498ef5b 100644 --- a/services/minecraft/src/debug/server.ts +++ b/services/minecraft/src/debug/server.ts @@ -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}`) }) } diff --git a/services/twitter-services/src/adapters/mcp-adapter.ts b/services/twitter-services/src/adapters/mcp-adapter.ts index 06dfa2c94..fd1712b65 100644 --- a/services/twitter-services/src/adapters/mcp-adapter.ts +++ b/services/twitter-services/src/adapters/mcp-adapter.ts @@ -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`) diff --git a/services/twitter-services/src/config/types.ts b/services/twitter-services/src/config/types.ts index 31aad2d73..f43ea159b 100644 --- a/services/twitter-services/src/config/types.ts +++ b/services/twitter-services/src/config/types.ts @@ -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: {