Files
bce78481fd 3.0.6
- Voice (Beta): talk to LU and hear it answer
- Computer control (Beta): a model works in the apps you approve, off by default
- Local models with image input download with their image part
- Create in chat (Beta): ask for an image or a clip in a Cloud chat
- Ten more chat models on LU Cloud
- A running task says what it is doing, and dialogs have a new look
- The Linux packages carry the voice engine
- Ten security fixes in the new features
- A microphone field in the settings, and seven fixes from user reports

Co-authored-by: Alan Vo <101240385+ALANDVO@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:43:43 -07:00

127 lines
6.7 KiB
TypeScript

import type { Connect } from 'vite'
import { postContentTypeAllowed, postContentTypeError } from '../src/lib/local-api-guard'
/**
* Der Wächter vor /local-api — Content-Type, CSRF-Header, Origin.
*
* WAS `port` HIER IST, UND WAS NICHT (KF-13). Er ist NICHT die Origin-Regel.
* Die Origin-Regel steht vollständig unten und lautet: zwei feste
* Tauri-Origins, sonst Loopback auf jedem Port. Ein fester Port als
* Listeneintrag wäre von dieser Regel ohnehin gedeckt und entschiede damit
* nichts — genau so ein Eintrag stand hier und las sich wie eine Zusicherung
* („nur DIESER Port"), die es nie gab.
*
* Was `port` ist: die Angabe, auf der der Server WIRKLICH hört, und damit die
* einzige Zahl, die eine Ablehnung sinnvoll nennen kann. Sie steht deshalb im
* 403-Text und nirgends sonst. Der Parameter bleibt auch aus dem zweiten
* Grund, aus dem er eingeführt wurde: ein fest verdrahteter Port lässt sich
* kein zweites Mal starten (`LU_DEV_PORT`, siehe vite.config.ts).
*/
/**
* K8 (GH #134, `npm run dev --host`): a deliberate LAN-exposed dev
* server rejected its OWN onboarding write requests with "403 Forbidden:
* Invalid Origin (CSRF Protection)": the page loaded from the machine's LAN
* IP (e.g. `http://192.168.1.23:5273`), which is neither `tauri://localhost`
* nor the loopback regex below, so every request from it hit the same wall
* as a real cross-origin attacker.
*
* `getLanOrigins`, when given, is called PER REQUEST (not once at guard
* creation) and must return only origins the SERVER itself resolved it is
* bound to (see dev-server/index.ts: Vite's own `resolvedUrls.network`,
* read after listen()), never anything derived from a request header. That
* is the whole DNS-rebinding lesson above, applied here too: `--host` widens
* what the guard accepts, but the widening still comes from the server's own
* configuration, not from what a caller claims. Omitted (or empty) when
* `--host` was not passed: the default stays loopback-and-Tauri-only.
*/
export function createLocalApiGuard(
port: number,
getLanOrigins?: () => string[],
): Connect.NextHandleFunction {
// Vites eigener Middleware-Typ statt einer geratenen Signatur: `next`
// ist hier Pflicht, weil dieser Wächter durchreicht statt zu antworten.
return (req, res, next) => {
// Exclude GET proxy-image/download from strict header checks (used in <img> tags and simple fetches)
if (req.method === 'GET' && (req.url?.startsWith('/proxy-image') || req.url?.startsWith('/proxy-download'))) {
return next();
}
// 1. Strict Content-Type enforcement for POST requests. The rule is
// application/json everywhere except /transcribe, whose body IS the
// raw recorded audio and was 415'd here before the whisper handler
// ever saw it (GitHub #115). The carve-out swaps the
// JSON requirement for an audio one, it does not drop the check.
if (req.method === 'POST') {
const contentType = String(req.headers['content-type'] || '');
if (!postContentTypeAllowed(req.url, contentType)) {
res.writeHead(415, { 'Content-Type': 'text/plain' });
res.end(postContentTypeError(req.url));
return;
}
}
// 2. Custom Header Requirement (CSRF Protection)
if (req.headers['x-locally-uncensored'] !== 'true') {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden: Missing x-locally-uncensored header (CSRF Protection)');
return;
}
// 3. Strict Origin Validation (Defense in Depth)
const origin = req.headers.origin;
if (origin) {
// The allowlist is built from constants only. It used to append
// `http(s)://${req.headers.host}` so a request always matched its
// own host — but the Host header is attacker-chosen under DNS
// rebinding: a page on evil.com whose DNS flips to 127.0.0.1 sends
// Origin *and* Host of evil.com, the two agree, and the check waved
// the request through to /shell-execute and /execute-code with no
// authentication at all. A value the caller supplies can never be
// the thing that authorises the caller.
// Loopback on any port stays allowed: Vite binds 5274+ when 5273 is
// busy and the page it serves then legitimately carries that origin
// (issue #51). That stays safe where the host header did
// not, because rebinding hands the attacker a *name* — the browser
// only stamps a literal 127.0.0.1/localhost origin on a page it
// really loaded from loopback, and `evil.localhost` (which Vite's
// own host check tolerates) does not match this pattern.
// KF-13 — DIE REGEL, AUSGESCHRIEBEN: zwei feste Tauri-Origins, und
// Loopback auf JEDEM Port. Mehr ist es nicht.
//
// Hier standen bis hierher zusätzlich `http://localhost:${port}` und
// `http://127.0.0.1:${port}`. Beide waren WIRKUNGSLOS: die Regex eine
// Zeile tiefer deckt jeden Loopback-Port ab, den festen eingeschlossen
// — es gab keine Anfrage, die die Liste annahm und die Regex ablehnte.
// Eine Zeile ohne beobachtbare Wirkung liest sich trotzdem wie eine
// Zusicherung („nur DIESER Port"), und die gab es nie.
//
// VERENGT WURDE NICHTS: die Regex bleibt die absichtlich weitere Regel
// (Begründung oben, issue #51). Sie zu verengen wäre eine
// Verhaltensänderung an der einzigen Zugangssperre vor /shell-execute
// und /execute-code, und sie würde den Fall brechen, für den sie da
// ist.
//
// `port` ist damit KEINE Regel mehr, sondern nur noch die DIAGNOSE in
// der Ablehnung: „Invalid Origin" allein sagt nicht, was erwartet war.
const allowedOrigins = ['tauri://localhost', 'http://tauri.localhost'];
const isLoopback = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
// K8: the server's own LAN origin(s), see the doc comment on
// getLanOrigins above. Read fresh on every request; [] when --host
// is not active, so a plain `npm run dev` behaves exactly as before.
const lanOrigins = getLanOrigins ? getLanOrigins() : [];
const isOwnLan = lanOrigins.includes(origin);
if (!allowedOrigins.includes(origin) && !isLoopback && !isOwnLan) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
const lanNote = lanOrigins.length > 0 ? `, and this server's own LAN origin (${lanOrigins.join(', ')})` : '';
res.end(
'Forbidden: Invalid Origin (CSRF Protection). Allowed: '
+ `${allowedOrigins.join(', ')}, and loopback on ANY port (this server: http://localhost:${port})${lanNote}.`,
);
return;
}
}
next();
}
}