#6 sandbox: true на обоих BrowserWindow (раньше false). Preload использует только contextBridge + ipcRenderer (оба sandbox-safe), никаких Node-built-ins. OS-уровневый sandbox изолирует renderer от GPU/IPC процессов; даже RCE в зависимости renderer'а не получит Node-доступа через preload. #17 self-host шрифтов через @fontsource/* пакеты. Раньше тянулись с fonts.googleapis.com — внешняя CSP-зависимость + отсутствие интернета = шрифты не загружались. Теперь .woff/.woff2 в bundle (22 файла × 15-30KB = ~500KB). Подкрутили CSP: убрали https://fonts.* origins, добавили connect-src 'self', base-uri 'self', frame-ancestors 'none'. #22 src/main/logger.ts — структурный лог с уровнями (debug/info/warn/error) и ротацией. Пишет в %APPDATA%/Exercise Reminder/logs/latest.log (≤1MB) и дублирует в console. При 1MB latest.log → prev.log (предыдущий prev.log удаляется). LAUDE_DEBUG=1 включает debug-уровень. Подключён в hot paths: store (corrupt/atomic write fails), updater (silent check errors), gsi-server (bad requests, handler throws), games/registry (GSI start, reconcile, match_end summary), games/dota2 (rejected token, POST_GAME detection). Особенно полезно для диагностики «челленджи не срабатывают»: лог покажет (а) пришёл ли вообще GSI payload (token verify), (б) детектировался ли POST_GAME, (в) сколько challenges были enabled и которые из них дали 0 reps. Logger — единственный файл с `eslint-disable no-console` (он намеренно дублирует в stderr).
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse
|
|
} from 'node:http'
|
|
import { log } from '../logger'
|
|
|
|
export type GsiHandler = (
|
|
payload: unknown,
|
|
headers: Record<string, string | string[] | undefined>
|
|
) => void
|
|
|
|
const PORT = 4701
|
|
/**
|
|
* Hard cap on incoming POST body. Real Dota GSI payloads are ~8 KB; anything
|
|
* larger is either a bug or a malicious local client trying to OOM us.
|
|
*/
|
|
const MAX_BODY_BYTES = 256 * 1024
|
|
|
|
let server: Server | null = null
|
|
const handlers: Map<string, GsiHandler> = new Map()
|
|
|
|
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
let received = 0
|
|
const chunks: Buffer[] = []
|
|
req.on('data', (c: Buffer) => {
|
|
received += c.length
|
|
if (received > MAX_BODY_BYTES) {
|
|
// Drop the connection so we don't keep buffering.
|
|
req.destroy(new Error('body too large'))
|
|
reject(new Error('body too large'))
|
|
return
|
|
}
|
|
chunks.push(c)
|
|
})
|
|
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
async function onRequest(
|
|
req: IncomingMessage,
|
|
res: ServerResponse
|
|
): Promise<void> {
|
|
// Reject browser-originated requests outright. Legitimate Dota GSI POSTs
|
|
// never include an Origin header; any value here means a webpage is poking
|
|
// our localhost endpoint via cross-origin fetch, which we never want.
|
|
if (req.headers.origin) {
|
|
res.statusCode = 403
|
|
res.end()
|
|
return
|
|
}
|
|
// Same intent for Sec-Fetch-Site: browsers always set it, Dota never does.
|
|
if (req.headers['sec-fetch-site']) {
|
|
res.statusCode = 403
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const route = (req.url ?? '/').split('?')[0]
|
|
const handler = handlers.get(route)
|
|
if (!handler) {
|
|
res.statusCode = 404
|
|
res.end()
|
|
return
|
|
}
|
|
if (req.method !== 'POST') {
|
|
res.statusCode = 405
|
|
res.end()
|
|
return
|
|
}
|
|
// Require JSON content-type. Browsers' "simple" requests in no-cors mode
|
|
// can only send text/plain or form-encoded — locking to application/json
|
|
// shrinks the cross-origin attack surface further.
|
|
const ct = String(req.headers['content-type'] ?? '').toLowerCase()
|
|
if (!ct.includes('application/json')) {
|
|
res.statusCode = 415
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
let payload: unknown
|
|
try {
|
|
const body = await readBody(req)
|
|
const text = body.toString('utf-8')
|
|
payload = text.length > 0 ? JSON.parse(text) : {}
|
|
} catch (err) {
|
|
// Log the real reason locally; do not echo it to the client.
|
|
log.warn('[gsi] bad request', err instanceof Error ? err.message : err)
|
|
res.statusCode = 400
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
try {
|
|
handler(payload, req.headers)
|
|
res.statusCode = 200
|
|
res.setHeader('Content-Type', 'text/plain')
|
|
res.end('ok')
|
|
} catch (err) {
|
|
log.error('[gsi] handler threw', err)
|
|
res.statusCode = 500
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
export async function startGsiServer(): Promise<void> {
|
|
if (server) return
|
|
await new Promise<void>((resolve, reject) => {
|
|
server = createServer(onRequest)
|
|
server.once('error', reject)
|
|
server.listen(PORT, '127.0.0.1', () => resolve())
|
|
})
|
|
}
|
|
|
|
export async function stopGsiServer(): Promise<void> {
|
|
if (!server) return
|
|
const s = server
|
|
server = null
|
|
// Free pending sockets so close() can resolve quickly even while Dota holds
|
|
// a long-poll connection open.
|
|
s.closeAllConnections?.()
|
|
await new Promise<void>((resolve) => s.close(() => resolve()))
|
|
}
|
|
|
|
export function registerGsiRoute(
|
|
route: string,
|
|
handler: GsiHandler
|
|
): () => void {
|
|
handlers.set(route, handler)
|
|
return () => {
|
|
// Only delete if we're still the registered handler — protects against
|
|
// double-register + unregister races where a newer handler took our slot.
|
|
if (handlers.get(route) === handler) handlers.delete(route)
|
|
}
|
|
}
|
|
|
|
export function getGsiBaseUrl(): string {
|
|
return `http://127.0.0.1:${PORT}`
|
|
}
|
|
|
|
export function getGsiPort(): number {
|
|
return PORT
|
|
}
|