chore+fix: repo hygiene, code-review fixes, audit cleanup
Three independent code reviews + a security audit produced ~200 findings.
This commit lands the high-impact subset. Tests pass (53), typecheck
clean, eslint clean (3 minor exhaustive-deps warnings left).
REPO HYGIENE
- Add .editorconfig, .prettierrc.json, .prettierignore.
- Add ESLint flat config (.eslintrc.cjs) — correctness-focused, no style
rules (Prettier owns formatting).
- Add `format` / `format:check` / `lint` npm scripts.
- Add CHANGELOG.md (Keep a Changelog format, back-filled to 0.1.x).
- Reformat all source via Prettier so future diffs stay small.
DATA SAFETY (src/main/store.ts)
- Atomic write (tmp + rename) with retry on transient EBUSY/EPERM —
was non-atomic writeFileSync, vulnerable to truncation on power loss.
- On corrupt JSON, rename to `app-state.json.corrupt-<ts>` instead of
silently overwriting the user's exercises/history with defaults.
- Validate parsed shape before merging — reject arrays/scalars where
objects expected; per-field array checks.
- Strip `id` from incoming patches in updateExercise/updateChallenge —
a runtime caller (IPC) could otherwise smuggle id changes through.
- clearHistory now refuses an unbounded wipe (no beforeTs => no-op);
callers must pass an explicit boundary.
- unref() the debounce timer so it doesn't keep the event loop alive.
SECURITY (src/main/*)
- gsi-server: hard 256 KB body cap (was unbounded — local OOM vector),
reject any Origin/Sec-Fetch-Site header (blocks browser CSRF from
visited pages), require application/json Content-Type, generic 400
on parse error (no error string echo to client), closeAllConnections
+ async close on stop.
- dota2: validate auth.token from payload with timingSafeEqual against
the per-install token — was unauthenticated, any local process could
forge match-end events. Narrow object shape before spread-merge to
avoid throws on hostile payloads like {player:"x"}. Reset latest /
prevState after match_end so the next match starts clean.
- ipc: gate `dev:simulateMatchEnd` registration behind `!app.isPackaged`
so it does not exist in shipped builds.
- preload: gate the matching `simulateMatchEnd` export behind
`import.meta.env.MODE !== 'production'` so the bundler dead-code-
eliminates it from the production preload bundle.
- windows: shell.openExternal allowlist (http/https/mailto only) — was
forwarding any URL, including file:/javascript:/custom URI handlers
(some Windows handlers have been RCE vectors). will-navigate blocks
navigation to anywhere except file:// or the dev URL.
CORRECTNESS (src/main/* + src/shared/*)
- shared/types.ts isQuietAt: fix wrap-around + day-of-week filter.
With from=22:00 to=07:00 days=[Mon..Fri], the window started THE
PREVIOUS DAY when we're in the AM half — old code checked today's
day-of-week and got the wrong answer Sat 02:00 and Mon 01:00. Now
the filter is evaluated against the window's START day. Also reject
malformed HH:MM strings instead of producing NaN.
- scheduler: call broadcastState() after firing exercises so the
renderer's Dashboard/Exercises pages don't show stale nextFireAt
until the next state-changing IPC. Guard powerMonitor listeners
against double-registration on dev hot-reload.
- dota2: fix `launchOptionStatus = steamRunning ? 'queued' : 'queued'`
tautology — both branches now correctly read 'queued'.
- steam-launch-options: replace `require('node:fs')` inside atomicWrite
with the top-level import; retry on transient EBUSY/EPERM.
CORRECTNESS (src/renderer/*)
- lib/history.ts: replace `today.getTime() - i * MS_DAY` arithmetic
with `setDate(date - i)` calendar arithmetic in dailyRepsRange and
currentStreak — DST transitions shift epoch math by ±1h and cause
dayKey() to emit duplicate or missing days at the boundary.
- lib/icon.tsx: restrict name lookup to ICON_CHOICES set — an arbitrary
string from a corrupted state file could otherwise resolve to
unrelated Lucide exports and crash the renderer.
- lib/format.ts: guard formatCountdown against NaN/Infinity.
- i18n/index.ts: replace regex-based interpolation with split/join so
variable values containing regex metacharacters interpolate
literally; warn in dev on missing keys; clamp pluralRu(-N) via abs.
- ReminderApp: keyboard shortcuts moved INTO ExerciseReminder so Enter
respects the stepper's `adjusted` flag (was always passing planned
reps). Stepper capped at 5× planned. Don't hijack Space when a
button is focused. `key={exercise.id+nextFireAt}` forces a fresh
component for back-to-back reminders so stepper state resets. Match
summary view gets Esc-to-close. Functional setMode in onMarkDone
avoids races against stale `mode.done`.
- UpdaterCard: guard against NaN/Infinity in download-progress events
(electron-updater fires early events with undefined fields).
- Games: gate DevPanel behind `import.meta.env.DEV` in addition to the
main-side IPC gate, and narrow the `simulateMatchEnd` access.
- Add aria-labels for the +/- stepper buttons (i18n keys added).
TESTS
- +2 quiet-hours tests covering wrap-around + day-filter combo and
malformed HH:MM fallback. Total 53 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { app } from 'electron'
|
||||
import type { GameProvider, ProviderEventHandler } from './provider'
|
||||
import { findGameInstall } from './steam'
|
||||
@@ -21,6 +27,7 @@ const LAUNCH_OPTION = '-gamestateintegration'
|
||||
|
||||
type DotaGsi = {
|
||||
provider?: { name?: string }
|
||||
auth?: { token?: string }
|
||||
map?: {
|
||||
game_state?: string
|
||||
win_team?: 'radiant' | 'dire' | 'none'
|
||||
@@ -38,6 +45,19 @@ type DotaGsi = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string equality. Avoids early-exit timing oracles that could
|
||||
* leak the token byte-by-byte to a local attacker who can measure response
|
||||
* latency on the loopback HTTP server. (Practical risk is tiny; correctness
|
||||
* matters anyway.)
|
||||
*/
|
||||
function safeEqualStrings(a: string, b: string): boolean {
|
||||
const A = Buffer.from(a, 'utf-8')
|
||||
const B = Buffer.from(b, 'utf-8')
|
||||
if (A.length !== B.length) return false
|
||||
return timingSafeEqual(A, B)
|
||||
}
|
||||
|
||||
function tokenStorePath(): string {
|
||||
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
|
||||
}
|
||||
@@ -115,7 +135,10 @@ export class Dota2Provider implements GameProvider {
|
||||
if (present) launchOptionStatus = 'applied'
|
||||
else {
|
||||
steamRunning = await isSteamRunning()
|
||||
launchOptionStatus = steamRunning ? 'queued' : 'queued'
|
||||
// Either Steam is open (we can't write while it runs -> 'queued') or
|
||||
// closed (apply on next ensureLaunchOption call -> still queued until
|
||||
// the watcher tick actually writes). 'queued' is correct for both.
|
||||
launchOptionStatus = 'queued'
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -134,7 +157,8 @@ export class Dota2Provider implements GameProvider {
|
||||
async install(): Promise<void> {
|
||||
if (!this.installPath) {
|
||||
const status = await this.detect()
|
||||
if (!status.installPath) throw new Error('Dota 2 не найдена в Steam-библиотеках')
|
||||
if (!status.installPath)
|
||||
throw new Error('Dota 2 не найдена в Steam-библиотеках')
|
||||
}
|
||||
const dir = cfgDir(this.installPath!)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
@@ -157,7 +181,13 @@ export class Dota2Provider implements GameProvider {
|
||||
|
||||
async start(emit: ProviderEventHandler): Promise<void> {
|
||||
this.emit = emit
|
||||
this.unregister = registerGsiRoute(ROUTE, (payload) => this.handle(payload as DotaGsi))
|
||||
// Defensive double-register guard: free any previous registration first.
|
||||
this.unregister?.()
|
||||
this.unregister = registerGsiRoute(ROUTE, (payload) => {
|
||||
// Runtime shape check — payload comes from a network socket.
|
||||
if (typeof payload !== 'object' || payload === null) return
|
||||
this.handle(payload as DotaGsi)
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
@@ -169,10 +199,34 @@ export class Dota2Provider implements GameProvider {
|
||||
}
|
||||
|
||||
private handle(g: DotaGsi): void {
|
||||
// Track latest snapshot so we have stats when the transition fires.
|
||||
if (g.player || g.map) this.latest = { ...this.latest, ...g, player: { ...this.latest?.player, ...g.player }, map: { ...this.latest?.map, ...g.map } }
|
||||
// Verify the per-install token. Dota always sends auth.token; anything
|
||||
// without it (or with the wrong one) is some other process on localhost
|
||||
// trying to fake a match-end event.
|
||||
const incoming = g.auth?.token
|
||||
if (
|
||||
typeof incoming !== 'string' ||
|
||||
!safeEqualStrings(incoming, this.token)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = g.map?.game_state ?? this.latest?.map?.game_state
|
||||
// Narrow the shape before spread-merging. A payload like `{player:"x"}`
|
||||
// would otherwise let `{...this.latest?.player, ...g.player}` throw.
|
||||
const playerObj =
|
||||
typeof g.player === 'object' && g.player !== null ? g.player : undefined
|
||||
const mapObj =
|
||||
typeof g.map === 'object' && g.map !== null ? g.map : undefined
|
||||
|
||||
if (playerObj || mapObj) {
|
||||
this.latest = {
|
||||
...this.latest,
|
||||
...g,
|
||||
player: { ...this.latest?.player, ...playerObj },
|
||||
map: { ...this.latest?.map, ...mapObj }
|
||||
}
|
||||
}
|
||||
|
||||
const state = mapObj?.game_state ?? this.latest?.map?.game_state
|
||||
if (!state) return
|
||||
|
||||
const prev = this.prevState
|
||||
@@ -209,6 +263,11 @@ export class Dota2Provider implements GameProvider {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reset stale state so the NEXT match starts from a clean slate even if
|
||||
// the user re-enters the same lobby or Dota's GSI restarts mid-session.
|
||||
this.latest = undefined
|
||||
this.prevState = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user