Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion server/services/examples/examples-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -62,9 +64,11 @@ export class ExamplesCache {
private readonly revisionFlights = new Map<RevisionCacheKey, SharedFlight>();
private totalSnapshotBytes = 0;
private readonly now: () => number;
private readonly protectedSources: ReadonlySet<SourceCacheKey>;

constructor(private readonly options: ExamplesCacheOptions) {
this.now = options.now ?? Date.now;
this.protectedSources = new Set(options.protectedSourceKeys);
}

getSource(key: SourceCacheKey): SourceCacheEntry | undefined {
Expand Down Expand Up @@ -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]);
Expand Down
67 changes: 50 additions & 17 deletions server/services/examples/examples-load-controller.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<SourcePriority, BoundedSemaphore>;
private readonly outbound: BoundedSemaphore;
private readonly starts: number[] = [];
private readonly overrideOutbound: BoundedSemaphore;
private readonly starts: Record<SourcePriority, number[]> = { default: [], override: [] };
private readonly loadPriority = new AsyncLocalStorage<SourcePriority>();
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<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const release = await this.loads.acquire(signal);
/** Runs one load; its outbound fetches inherit the priority. */
async runLoad<T>(operation: () => Promise<T>, signal?: AbortSignal, priority: SourcePriority = "override"): Promise<T> {
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<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
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);
}
}
5 changes: 4 additions & 1 deletion server/services/examples/examples-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion server/services/examples/source-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions ssot/ssot_function_definition_ExternalExamples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
157 changes: 157 additions & 0 deletions tests/server/examples/default-source-protection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
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<string> {
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<void>((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;
});
});
Loading