fix(desktop): renderer permissions, Origin rewrite scope, route state fallbacks

- The permission request handler granted every renderer permission; its
  media check called callback(true) in both branches. Now only 'media'
  (dictation microphone) and 'clipboard-sanitized-write' (copy buttons)
  are granted; everything else is denied and logged.

- onBeforeSendHeaders rewrote the Origin header to
  http://localhost:5173 on every outgoing request, including third-party
  hosts. goosed's origin policy only concerns loopback targets, so the
  rewrite is now scoped to them.

- Route wrappers fell back to window.history.state for view options, but
  react-router stores user state under history.state.usr, so the
  fallback could only ever produce the router's wrapper object — and
  SettingsRoute then mutated it. Read location.state only, and build the
  settings view options instead of mutating.

- ProgressiveMessageList marked the last *rendered* message as streaming,
  which targets the wrong message while progressive batches are still
  loading. Compare against the full message list instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Douwe M Osinga 2026-07-07 17:13:42 -07:00
parent b9b9b819fe
commit e0cf1d9801
3 changed files with 30 additions and 27 deletions

View file

@ -91,8 +91,7 @@ const PairRouteWrapper = ({
}) => {
const { extensionsList } = useConfig();
const location = useLocation();
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const routeState = (location.state as PairRouteState) ?? {};
const [searchParams, setSearchParams] = useSearchParams();
const isCreatingSessionRef = useRef(false);
const navigate = useNavigate();
@ -185,15 +184,11 @@ const SettingsRoute = () => {
const [searchParams] = useSearchParams();
const setView = useNavigation();
// Get viewOptions from location.state, history.state, or URL search params
const viewOptions =
(location.state as SettingsViewOptions) || (window.history.state as SettingsViewOptions) || {};
// If section is provided via URL search params, add it to viewOptions
const sectionFromUrl = searchParams.get('section');
if (sectionFromUrl) {
viewOptions.section = sectionFromUrl;
}
const viewOptions: SettingsViewOptions = {
...((location.state as SettingsViewOptions) ?? {}),
...(sectionFromUrl ? { section: sectionFromUrl } : {}),
};
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
};
@ -272,11 +267,7 @@ const ExtensionsRoute = () => {
const navigate = useNavigate();
const location = useLocation();
// Get viewOptions from location.state or history.state (for deep link extensions)
const viewOptions =
(location.state as ExtensionsViewOptions) ||
(window.history.state as ExtensionsViewOptions) ||
{};
const viewOptions = (location.state as ExtensionsViewOptions) ?? {};
return (
<ExtensionsView

View file

@ -299,8 +299,7 @@ export default function ProgressiveMessageList({
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
index === messages.length - 1 &&
message.role === 'assistant'
}
submitElicitationResponse={submitElicitationResponse}

View file

@ -306,6 +306,15 @@ interface BackendCertificateTrustRegistration {
const trustedBackendCertificates = new Set<BackendCertificateTrust>();
function isLoopbackUrl(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
} catch {
return false;
}
}
function normalizeHostname(hostname: string): string {
return hostname.toLowerCase();
}
@ -2415,16 +2424,15 @@ async function appMain() {
registerUpdateIpcHandlers();
// Handle microphone permission requests
// 'media' covers the dictation microphone; 'clipboard-sanitized-write'
// covers navigator.clipboard copy buttons. Everything else is denied.
const allowedRendererPermissions = new Set(['media', 'clipboard-sanitized-write']);
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
console.log('Permission requested:', permission);
// Allow microphone and media access
if (permission === 'media') {
callback(true);
} else {
// Default behavior for other permissions
callback(true);
const allowed = allowedRendererPermissions.has(permission);
if (!allowed) {
log.warn(`Denied renderer permission request: ${permission}`);
}
callback(allowed);
});
// Add CSP headers to all sessions, recomputed on every response so external
@ -2451,9 +2459,14 @@ async function appMain() {
// Register global shortcuts based on settings
registerGlobalShortcuts();
// goosed validates the Origin header of incoming requests. Rewrite it for
// loopback (goosed) targets only; other hosts must not receive a fabricated
// localhost origin.
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders['Origin'] = 'http://localhost:5173';
callback({ cancel: false, requestHeaders: details.requestHeaders });
if (isLoopbackUrl(details.url)) {
details.requestHeaders['Origin'] = 'http://localhost:5173';
}
callback({ requestHeaders: details.requestHeaders });
});
if (settings.showMenuBarIcon) {