Audit pass with the threats that actually apply to a self-hosted, account-less app: getting the Ollama key throttled or banned, and someone grinding the app down. Per-route windows already existed; these are the missing layers. - lib/model-guard.ts: every route that spends a model call now takes a slot — 60 calls per window across all clients (RATE_LIMIT_MODEL_TOTAL), 3 in flight per IP, 6 in total — and the slot is released when the call finishes, on errors and on client disconnects. Over the concurrency cap answers 429 kind=busy, not a punishment. Slots are taken after every cheap rejection so a malformed request cannot hold one (found by the new suite: early 400s were leaking slots). - Images must be base64 data URLs (dataUrlSchema in lib/schemas): a plain URL in an images field made Ollama fetch an arbitrary address on our behalf. - Push endpoints must be a real push service (PUSH_ALLOWED_HOSTS, defaulting to FCM/Mozilla/Apple/WNS): the server POSTs to whatever is stored. - The rate-limit identity is the LAST x-forwarded-for hop. Reading the first let a client spoof its own IP and dodge every per-IP limit. - Disk guards: reports.jsonl stops at REPORTS_MAX_BYTES (8 MB), reminder devices cap at PUSH_MAX_SUBSCRIPTIONS (50). - Security headers: frame-ancestors 'none' + X-Frame-Options DENY, nosniff, a referrer policy and a Permissions-Policy without camera/mic/geo/payment. New tests/security-test.mjs (13 checks, registered in run-all) proves each guard against a slow stub Ollama with deliberately tiny limits. README: security notes + rate-limit tables updated. Version 0.1.3, SW -> v51.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
/**
|
|
* Server-side configuration, read from the environment on every request so
|
|
* that editing `.env.local` only requires a dev-server restart.
|
|
*
|
|
* Never import this into a client component: it would leak the API key.
|
|
*/
|
|
|
|
import { sanitiseApiKey } from "./http-guard";
|
|
import { translate, type Locale } from "./i18n-dict";
|
|
import path from "node:path";
|
|
|
|
export type AuthMode = "user-key" | "server" | "local";
|
|
|
|
export type ServerConfig = {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
authMode: AuthMode;
|
|
visionModel: string;
|
|
textModel: string;
|
|
/** Allowlist for the chat model picker (CHAT_MODELS). Never client-supplied. */
|
|
chatModels: string[];
|
|
/** True when talking straight to https://ollama.com (needs an API key). */
|
|
directCloud: boolean;
|
|
timeoutMs: number;
|
|
healthTimeoutMs: number;
|
|
/** `false` sends `think: false`; `null` omits the field entirely. */
|
|
think: boolean | null;
|
|
defaultActivityCount: number;
|
|
rateLimits: {
|
|
windowMs: number;
|
|
generate: number;
|
|
grade: number;
|
|
homework: number;
|
|
health: number;
|
|
refine: number;
|
|
sync: number;
|
|
push: number;
|
|
chat: number;
|
|
reports: number;
|
|
/** All Ollama calls together per window (account-level guard). */
|
|
modelTotal: number;
|
|
/** In-flight model calls per client IP. */
|
|
concurrent: number;
|
|
/** In-flight model calls across all clients. */
|
|
concurrentTotal: number;
|
|
};
|
|
sync: {
|
|
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
|
dir: string;
|
|
/** Per-blob cap in bytes (SYNC_MAX_BYTES). */
|
|
maxBytes: number;
|
|
/** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */
|
|
totalBytes: number;
|
|
};
|
|
push: {
|
|
/** VAPID keys — reminders are hidden entirely when either is empty. */
|
|
publicKey: string;
|
|
privateKey: string;
|
|
subject: string;
|
|
/** Where push subscriptions live (PUSH_DIR). */
|
|
dir: string;
|
|
/** Cap on stored subscriptions (PUSH_MAX_SUBSCRIPTIONS). */
|
|
maxSubscriptions: number;
|
|
/**
|
|
* Hosts the server is allowed to POST reminders to (PUSH_ALLOWED_HOSTS,
|
|
* comma separated). Defaults to the real push services; a self-hosted
|
|
* push service can be added, and the test suite points it at its own stub.
|
|
*/
|
|
allowedHosts: string[];
|
|
};
|
|
reports: {
|
|
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
|
|
dir: string;
|
|
/** Stop appending past this size (REPORTS_MAX_BYTES) — disk guard. */
|
|
maxBytes: number;
|
|
};
|
|
};
|
|
|
|
/** What a single request should use to talk to Ollama. */
|
|
export type OllamaAuth = {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
directCloud: boolean;
|
|
authMode: AuthMode;
|
|
};
|
|
|
|
function positiveInt(value: string | undefined, fallback: number): number {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function trimTrailingSlash(url: string): string {
|
|
return url.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function getServerConfig(): ServerConfig {
|
|
const baseUrl = trimTrailingSlash(
|
|
process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434",
|
|
);
|
|
const apiKey = process.env.OLLAMA_API_KEY?.trim() || "";
|
|
|
|
const rawAuthMode = process.env.OLLAMA_AUTH?.trim().toLowerCase();
|
|
const authMode: AuthMode =
|
|
rawAuthMode === "local" || rawAuthMode === "server" || rawAuthMode === "user-key"
|
|
? rawAuthMode
|
|
: rawAuthMode === "none"
|
|
? "local"
|
|
: "user-key";
|
|
|
|
let directCloud = false;
|
|
try {
|
|
const host = new URL(baseUrl).hostname.toLowerCase();
|
|
directCloud = host === "ollama.com" || host.endsWith(".ollama.com");
|
|
} catch {
|
|
directCloud = false;
|
|
}
|
|
|
|
const thinkRaw = process.env.OLLAMA_THINK?.trim().toLowerCase();
|
|
const think =
|
|
thinkRaw === "false" ? false : thinkRaw === "true" ? true : null;
|
|
|
|
const count = Number.parseInt(
|
|
process.env.FREEZMO_DEFAULT_ACTIVITY_COUNT ?? "",
|
|
10,
|
|
);
|
|
|
|
const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud";
|
|
const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud";
|
|
|
|
// Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and
|
|
// vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
|
|
const requestedChatModels = (process.env.CHAT_MODELS ?? "")
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
const chatModels: string[] = [];
|
|
const seenModels = new Set<string>();
|
|
for (const entry of requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel]) {
|
|
const canonical = canonicalModelName(entry);
|
|
if (seenModels.has(canonical)) continue;
|
|
seenModels.add(canonical);
|
|
chatModels.push(entry);
|
|
}
|
|
|
|
return {
|
|
baseUrl,
|
|
apiKey,
|
|
authMode,
|
|
visionModel,
|
|
textModel,
|
|
chatModels,
|
|
directCloud,
|
|
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
|
|
healthTimeoutMs:
|
|
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
|
|
think,
|
|
defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10,
|
|
rateLimits: {
|
|
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
|
|
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
|
|
grade: positiveInt(process.env.RATE_LIMIT_GRADE, 40),
|
|
// Homework is one to two model calls, like a deck.
|
|
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
|
|
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
|
// Homework follow-ups (short rewrites) can be used more freely.
|
|
refine: positiveInt(process.env.RATE_LIMIT_REFINE, 30),
|
|
// Sync is cheap (no model calls) but shouldn't be hammered.
|
|
sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60),
|
|
// Push subscription changes and test sends.
|
|
push: positiveInt(process.env.RATE_LIMIT_PUSH, 30),
|
|
// Chat turns are one model call each, but users send them in bursts.
|
|
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
|
|
// Question reports are cheap file appends.
|
|
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20),
|
|
// Every Ollama call, all clients together: the account-level guard. A
|
|
// flood would otherwise get the key throttled or banned.
|
|
modelTotal: positiveInt(process.env.RATE_LIMIT_MODEL_TOTAL, 60),
|
|
// In-flight model calls: per client IP, and across all clients.
|
|
concurrent: positiveInt(process.env.RATE_LIMIT_CONCURRENT, 3),
|
|
concurrentTotal: positiveInt(process.env.RATE_LIMIT_CONCURRENT_TOTAL, 6),
|
|
},
|
|
sync: {
|
|
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"),
|
|
maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024),
|
|
totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024),
|
|
},
|
|
push: {
|
|
publicKey: process.env.VAPID_PUBLIC_KEY?.trim() || "",
|
|
privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
|
|
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
|
|
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
|
|
maxSubscriptions: positiveInt(process.env.PUSH_MAX_SUBSCRIPTIONS, 50),
|
|
allowedHosts: (process.env.PUSH_ALLOWED_HOSTS ?? "")
|
|
.split(",")
|
|
.map((entry) => entry.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.concat(
|
|
process.env.PUSH_ALLOWED_HOSTS
|
|
? []
|
|
: [
|
|
"fcm.googleapis.com",
|
|
"updates.push.services.mozilla.com",
|
|
".push.apple.com",
|
|
".notify.windows.com",
|
|
],
|
|
),
|
|
},
|
|
reports: {
|
|
dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"),
|
|
maxBytes: positiveInt(process.env.REPORTS_MAX_BYTES, 8 * 1024 * 1024),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Human-readable explanation for every auth configuration problem, so the UI
|
|
* never has to show a bare "unauthorised".
|
|
*/
|
|
const AUTH_ERROR_KEYS: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
|
|
missing_key: "server.authMissingKey",
|
|
server_key_missing: "server.authServerKeyMissing",
|
|
local_with_cloud_url: "server.authLocalCloud",
|
|
};
|
|
|
|
export function authErrorMessage(locale: Locale, error: keyof typeof AUTH_ERROR_KEYS): string {
|
|
return translate(locale, AUTH_ERROR_KEYS[error]);
|
|
}
|
|
|
|
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
|
|
if (error === "missing_key") return "needs_key";
|
|
return "config";
|
|
}
|
|
|
|
/** Header a browser uses to hand over its own key for a single request. */
|
|
export const API_KEY_HEADER = "x-ollama-key";
|
|
|
|
|
|
|
|
/**
|
|
* Works out which credentials a request may use.
|
|
*
|
|
* - `user-key` (default): the key must come from the browser. Nothing is
|
|
* stored server-side, ever — it is used for this request and dropped.
|
|
* - `server`: the key comes from `.env.local` (single-user installs).
|
|
* - `local`: neither; a local daemon signed in with `ollama signin`.
|
|
*/
|
|
export function resolveAuth(
|
|
config: ServerConfig,
|
|
request: Request,
|
|
): { auth: OllamaAuth } | { error: "missing_key" | "server_key_missing" | "local_with_cloud_url" } {
|
|
const headerKey = sanitiseApiKey(request.headers.get(API_KEY_HEADER));
|
|
|
|
if (config.authMode === "local") {
|
|
// A daemon without sign-in can't talk to the cloud API: catch the
|
|
// misconfiguration here instead of returning a confusing 401 later.
|
|
if (config.directCloud) return { error: "local_with_cloud_url" };
|
|
return {
|
|
auth: { baseUrl: config.baseUrl, apiKey: "", directCloud: config.directCloud, authMode: "local" },
|
|
};
|
|
}
|
|
|
|
if (config.authMode === "server") {
|
|
if (!config.apiKey) return { error: "server_key_missing" };
|
|
return {
|
|
auth: { baseUrl: config.baseUrl, apiKey: config.apiKey, directCloud: config.directCloud, authMode: "server" },
|
|
};
|
|
}
|
|
|
|
if (!headerKey) return { error: "missing_key" };
|
|
return {
|
|
auth: { baseUrl: config.baseUrl, apiKey: headerKey, directCloud: config.directCloud, authMode: "user-key" },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cloud models are addressed as `<model>:cloud` (or `<model>-cloud`) by a
|
|
* local daemon, but the direct cloud API uses the plain library name
|
|
* (`gemma4:31b-cloud` -> `gemma4:31b`, `glm-5.3-flash:cloud` -> `glm-5.3-flash`).
|
|
* Normalise so the same .env works in both modes.
|
|
*/
|
|
export function resolveModelName(model: string, directCloud: boolean): string {
|
|
if (!directCloud) return model;
|
|
if (model.endsWith(":cloud")) return model.slice(0, -":cloud".length);
|
|
if (model.endsWith("-cloud")) return model.slice(0, -"-cloud".length);
|
|
return model;
|
|
}
|
|
|
|
/** Strip local/daemon routing suffixes so model names can be compared. */
|
|
export function canonicalModelName(model: string): string {
|
|
return model
|
|
.replace(/:cloud$/, "")
|
|
.replace(/-cloud$/, "")
|
|
.toLowerCase();
|
|
}
|