diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ff67c4195..93b01fb86 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -158,6 +158,11 @@ development startup reject the flag. no request is sent before the announced reset, and browser overrides leave a reserve of 10 lookups to the operator's default Course. The optional `UNOSIM_GITHUB_TOKEN` is sent to `api.github.com` only and never logged. +- Browser overrides cannot crowd out the operator's default Course: default and + override loads use separate load slots, queues and load-start budgets of the + configured size, override loads may hold only part of the outbound fetch cap + (a quarter is reserved for the default), and the source cache never evicts + the default source. - Tutor credentials are request-scoped personal KI:connect keys held only in browser memory; local loopback HTTP is allowed and deployed traffic must use HTTPS through the gateway. diff --git a/server/services/examples/examples-cache.ts b/server/services/examples/examples-cache.ts index f286a1e5f..b672055fe 100644 --- a/server/services/examples/examples-cache.ts +++ b/server/services/examples/examples-cache.ts @@ -46,6 +46,8 @@ export interface ExamplesCacheOptions { maxSnapshots: number; maxSnapshotBytes: number; now?: () => number; + /** Sources the LRU never evicts: the operator's default Course, which overrides must not displace. */ + protectedSourceKeys?: readonly SourceCacheKey[]; } interface SharedFlight { @@ -62,9 +64,11 @@ export class ExamplesCache { private readonly revisionFlights = new Map(); private totalSnapshotBytes = 0; private readonly now: () => number; + private readonly protectedSources: ReadonlySet; constructor(private readonly options: ExamplesCacheOptions) { this.now = options.now ?? Date.now; + this.protectedSources = new Set(options.protectedSourceKeys); } getSource(key: SourceCacheKey): SourceCacheEntry | undefined { @@ -285,7 +289,7 @@ export class ExamplesCache { private evictOldestSource(): boolean { const oldest = [...this.sources.entries()] - .filter(([key]) => !this.sourceFlights.has(key)) + .filter(([key]) => !this.sourceFlights.has(key) && !this.protectedSources.has(key)) .sort(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt)[0]; if (!oldest) return false; this.sources.delete(oldest[0]); diff --git a/server/services/examples/examples-load-controller.ts b/server/services/examples/examples-load-controller.ts index 17151a88e..04f9580c3 100644 --- a/server/services/examples/examples-load-controller.ts +++ b/server/services/examples/examples-load-controller.ts @@ -1,4 +1,6 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { ExamplesError } from "./examples-error"; +import type { SourcePriority } from "./github-api-budget"; interface Waiter { resolve: (release: () => void) => void; @@ -64,56 +66,87 @@ export interface ExamplesLoadControllerOptions { now?: () => number; } +/** Outbound fetches kept for loads of the operator's default Course. */ +function defaultOutboundReserve(maxOutboundFetches: number): number { + return maxOutboundFetches > 1 ? Math.max(1, Math.floor(maxOutboundFetches / 4)) : 0; +} + +/** + * Bounds remote examples loading. Loads of the operator's default Course and + * browser overrides, which any client can trigger with arbitrary repositories + * and refs, use separate load slots, queues and load-start budgets of the same + * size, so overrides can never deny the default a load. Outbound fetches share + * one cap, but override loads may hold only part of it; the rest is reserved + * for default loads. + */ export class ExamplesLoadController { - private readonly loads: BoundedSemaphore; + private readonly loads: Record; private readonly outbound: BoundedSemaphore; - private readonly starts: number[] = []; + private readonly overrideOutbound: BoundedSemaphore; + private readonly starts: Record = { default: [], override: [] }; + private readonly loadPriority = new AsyncLocalStorage(); private readonly now: () => number; constructor(private readonly options: ExamplesLoadControllerOptions) { this.now = options.now ?? Date.now; - this.loads = new BoundedSemaphore( + const loadSlots = () => new BoundedSemaphore( options.maxConcurrentLoads, options.maxLoadQueue, () => new ExamplesError("LOAD_CAPACITY_EXCEEDED", "External examples load capacity is exhausted"), ); - this.outbound = new BoundedSemaphore( - options.maxOutboundFetches, + this.loads = { default: loadSlots(), override: loadSlots() }; + const fetchSlots = (capacity: number) => new BoundedSemaphore( + capacity, Number.MAX_SAFE_INTEGER, () => new ExamplesError("LOAD_CAPACITY_EXCEEDED", "External examples fetch capacity is exhausted"), ); + this.outbound = fetchSlots(options.maxOutboundFetches); + this.overrideOutbound = fetchSlots(options.maxOutboundFetches - defaultOutboundReserve(options.maxOutboundFetches)); } - async runLoad(operation: () => Promise, signal?: AbortSignal): Promise { - const release = await this.loads.acquire(signal); + /** Runs one load; its outbound fetches inherit the priority. */ + async runLoad(operation: () => Promise, signal?: AbortSignal, priority: SourcePriority = "override"): Promise { + const release = await this.loads[priority].acquire(signal); try { - this.recordLoadStart(); - return await operation(); + this.recordLoadStart(priority); + return await this.loadPriority.run(priority, operation); } finally { release(); } } async runOutbound(operation: () => Promise, signal?: AbortSignal): Promise { - const release = await this.outbound.acquire(signal); + const releaseShare = this.loadPriority.getStore() === "override" + ? await this.overrideOutbound.acquire(signal) + : undefined; try { - return await operation(); + const release = await this.outbound.acquire(signal); + try { + return await operation(); + } finally { + release(); + } } finally { - release(); + releaseShare?.(); } } getStats() { - return { loads: this.loads.getStats(), outbound: this.outbound.getStats() }; + return { + loads: this.loads.override.getStats(), + defaultLoads: this.loads.default.getStats(), + outbound: this.outbound.getStats(), + }; } - private recordLoadStart(): void { + private recordLoadStart(priority: SourcePriority): void { + const starts = this.starts[priority]; const now = this.now(); const cutoff = now - 60_000; - while (this.starts[0] !== undefined && this.starts[0] <= cutoff) this.starts.shift(); - if (this.starts.length >= this.options.globalLoadStartsPerMinute) { + while (starts[0] !== undefined && starts[0] <= cutoff) starts.shift(); + if (starts.length >= this.options.globalLoadStartsPerMinute) { throw new ExamplesError("RATE_LIMITED", "External examples load rate exceeded", 60); } - this.starts.push(now); + starts.push(now); } } diff --git a/server/services/examples/examples-repository.ts b/server/services/examples/examples-repository.ts index d79d409c1..3b2960e28 100644 --- a/server/services/examples/examples-repository.ts +++ b/server/services/examples/examples-repository.ts @@ -11,7 +11,7 @@ import type { } from "@shared/examples"; import { config, type ParsedExamplesConfig } from "../../config"; import { BuiltInProvider } from "./built-in-provider"; -import { ExamplesCache, type RevisionCacheEntry } from "./examples-cache"; +import { ExamplesCache, type RevisionCacheEntry, toSourceCacheKey } from "./examples-cache"; import type { TutorCapability } from "../course-content/course-content-loader"; import { ExamplesError } from "./examples-error"; import { GitHubRevisionResolver } from "./github-revision-resolver"; @@ -53,6 +53,9 @@ export class ExamplesRepository implements TutorCourseContentResolver { maxSources: this.examplesConfig.maxSources, maxSnapshots: this.examplesConfig.snapshotCacheMaxEntries, maxSnapshotBytes: this.examplesConfig.snapshotCacheMaxBytes, + protectedSourceKeys: this.examplesConfig.mode === "repository-ref" && this.examplesConfig.repository + ? [toSourceCacheKey(this.examplesConfig.repository, this.examplesConfig.ref)] + : [], }); this.sourceProvider = options.sourceProvider ?? new SourceProvider( new GitHubRevisionResolver(fetcher), diff --git a/server/services/examples/source-provider.ts b/server/services/examples/source-provider.ts index f48fd25cd..298ff7a86 100644 --- a/server/services/examples/source-provider.ts +++ b/server/services/examples/source-provider.ts @@ -97,7 +97,7 @@ export class SourceProvider { status: cachedSnapshot ? "cache" as const : "remote" as const, stale: false, }; - }, signal), + }, signal, context.sourcePriority ?? "default"), context.signal, ); } catch (error) { diff --git a/ssot/ssot_function_definition_ExternalExamples.md b/ssot/ssot_function_definition_ExternalExamples.md index adcf7b0e9..2d468e807 100644 --- a/ssot/ssot_function_definition_ExternalExamples.md +++ b/ssot/ssot_function_definition_ExternalExamples.md @@ -256,6 +256,10 @@ Der Server MUSS weiterhin erzwingen: - keine Redirects; - kanonische Repository-/Ref-Werte und sichere Dateipfade; - Timeouts, Größen-, Datei-, Parallelitäts-, Rate- und Cache-Grenzen; +- Browser-Overrides können den operatorseitigen Default-Course nicht + verdrängen: getrennte Load-Slots, Warteschlangen und Load-Start-Budgets, + eine Reserve an Upstream-Fetches für den Default und kein LRU-Evict der + Default-Quelle; - strikte GitHub-Response-, Manifest- und Dateischemata; - vollständige Snapshot-Validierung vor Aktivierung. diff --git a/tests/server/examples/default-source-protection.test.ts b/tests/server/examples/default-source-protection.test.ts new file mode 100644 index 000000000..7175e356c --- /dev/null +++ b/tests/server/examples/default-source-protection.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { ExamplesCache, toSourceCacheKey } from "../../../server/services/examples/examples-cache"; +import { ExamplesLoadController } from "../../../server/services/examples/examples-load-controller"; +import { SourceProvider, type RequestContext } from "../../../server/services/examples/source-provider"; + +// The operator's default Course must stay loadable while browser overrides, +// which any client can trigger with arbitrary repositories and refs, are spammed. + +const DEFAULT_REPOSITORY = "course/default"; +const DEFAULT_REF = "main"; +const defaultContext: RequestContext = { identity: "default", requestId: "default", sourcePriority: "default" }; +const overrideContext: RequestContext = { identity: "attacker", requestId: "attacker", sourcePriority: "override" }; +const revision = (marker: string) => marker.repeat(40).slice(0, 40); +const gates: Array<() => void> = []; +let released = false; + +function releaseGates(): void { + released = true; + for (const release of gates.splice(0)) release(); +} + +afterEach(() => { + releaseGates(); + released = false; +}); + +function snapshot() { + return { + contentBytes: 10, + examples: [{ + id: "blink", title: "Blink", category: "Basics", main: "main.ino", source: "external" as const, + files: [{ name: "main.ino", path: "main.ino", content: "void setup(){} void loop(){}" }], + }], + }; +} + +function harness( + resolveOverride: (repository: string, ref: string) => Promise, + options: { maxSources?: number } = {}, +) { + let now = 1_000_000; + let defaultResolutions = 0; + const cache = new ExamplesCache({ + maxSources: options.maxSources ?? 32, + maxSnapshots: 64, + maxSnapshotBytes: 1_048_576, + now: () => now, + protectedSourceKeys: [toSourceCacheKey(DEFAULT_REPOSITORY, DEFAULT_REF)], + }); + const loads = new ExamplesLoadController({ + maxConcurrentLoads: 4, maxLoadQueue: 4, maxOutboundFetches: 8, globalLoadStartsPerMinute: 20, now: () => now, + }); + const resolver = { + async resolve(repository: string, ref: string) { + if (repository === DEFAULT_REPOSITORY) { + defaultResolutions++; + return revision("d"); + } + return resolveOverride(repository, ref); + }, + }; + const provider = new SourceProvider(resolver, { load: async () => snapshot() }, cache, loads, { + refreshMs: 300_000, refreshRetryMs: 30_000, now: () => now, + }); + return { + provider, + cache, + advance: (ms: number) => { now += ms; }, + defaultResolutions: () => defaultResolutions, + loadDefault: (requireFresh = true) => provider.resolve(DEFAULT_REPOSITORY, DEFAULT_REF, defaultContext, requireFresh), + }; +} + +function hanging(): Promise { + if (released) return Promise.reject(new Error("released")); + return new Promise((_resolve, reject) => { gates.push(() => reject(new Error("released"))); }); +} + +describe("default Course protection against browser override spam", () => { + it("loads the default fresh after overrides used up their load-start budget", async () => { + const h = harness(async () => { throw new Error("404"); }); + await h.loadDefault(); + h.advance(301_000); + + // One client, one request after the other: each failing override starts a load. + const codes: string[] = []; + for (let index = 0; index < 30; index++) { + await h.provider.resolve("attacker/repo", `x${index}`, overrideContext, false) + .catch((error: { code?: string }) => codes.push(error.code ?? "error")); + } + expect(codes).toHaveLength(30); + expect(codes).toContain("RATE_LIMITED"); + + await expect(h.loadDefault()).resolves.toMatchObject({ revision: revision("d"), stale: false }); + }); + + it("loads the default while overrides occupy every override load slot and queue position", async () => { + const h = harness(() => hanging()); + const blocked = Array.from({ length: 8 }, (_, index) => + h.provider.resolve("attacker/repo", `slow${index}`, overrideContext, false).catch(() => undefined)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await expect(h.provider.resolve("attacker/repo", "one-more", overrideContext, false)) + .rejects.toMatchObject({ code: "LOAD_CAPACITY_EXCEEDED" }); + + await expect(h.loadDefault()).resolves.toMatchObject({ revision: revision("d") }); + + releaseGates(); + await Promise.all(blocked); + }); + + it("keeps the default source when overrides fill the source cache", async () => { + const h = harness(async (repository) => revision(repository.endsWith("a") ? "a" : "b"), { maxSources: 2 }); + await h.loadDefault(); + h.advance(1_000); + + for (const repository of ["other/a", "other/b", "other/a", "other/b"]) { + h.advance(1_000); + await h.provider.resolve(repository, "main", overrideContext, false); + } + + expect(h.cache.getSource(toSourceCacheKey(DEFAULT_REPOSITORY, DEFAULT_REF))).toBeDefined(); + await expect(h.loadDefault()).resolves.toMatchObject({ status: "cache" }); + expect(h.defaultResolutions()).toBe(1); + }); +}); + +describe("outbound fetch reserve", () => { + it("lets a default load fetch while override loads saturate their outbound share", async () => { + const controller = new ExamplesLoadController({ + maxConcurrentLoads: 4, maxLoadQueue: 4, maxOutboundFetches: 4, globalLoadStartsPerMinute: 60, + }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let activeOverrideFetches = 0; + let maximumOverrideFetches = 0; + const override = controller.runLoad(async () => { + await Promise.all(Array.from({ length: 8 }, () => controller.runOutbound(async () => { + activeOverrideFetches++; + maximumOverrideFetches = Math.max(maximumOverrideFetches, activeOverrideFetches); + await gate; + activeOverrideFetches--; + }))); + }, undefined, "override"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const defaultFetch = controller.runLoad(() => controller.runOutbound(async () => "fetched"), undefined, "default"); + const outcome = await Promise.race([ + defaultFetch, + new Promise((resolve) => setTimeout(() => resolve("blocked"), 200)), + ]); + + expect(outcome).toBe("fetched"); + expect(maximumOverrideFetches).toBeLessThan(4); + release(); + await override; + }); +});