Skip to content
Closed
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
47 changes: 47 additions & 0 deletions ui/desktop/src/appProtocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
GOOSE_SESSION_PARTITION,
PACKAGED_RENDERER_ORIGIN,
packagedRendererUrl,
rendererContentType,
resolvePackagedRendererPath,
} from './appProtocol';

describe('appProtocol', () => {
it('uses the packaged renderer app origin', () => {
expect(PACKAGED_RENDERER_ORIGIN).toBe('goose-app://goose');
expect(GOOSE_SESSION_PARTITION).toBe('persist:goose');
expect(packagedRendererUrl().toString()).toBe('goose-app://goose/index.html');
});

it('resolves packaged renderer asset paths under the renderer root', () => {
const root = path.resolve('/tmp/goose-renderer');

expect(resolvePackagedRendererPath('goose-app://goose/', root)).toBe(
path.join(root, 'index.html')
);
expect(resolvePackagedRendererPath('goose-app://goose/assets/index.js', root)).toBe(
path.join(root, 'assets', 'index.js')
);
});

it('rejects non-renderer URLs and path traversal', () => {
const root = path.resolve('/tmp/goose-renderer');

expect(resolvePackagedRendererPath('https://goose/index.html', root)).toBeNull();
expect(resolvePackagedRendererPath('goose-app://other/index.html', root)).toBeNull();
expect(resolvePackagedRendererPath('goose-app://goose/%2e%2e/settings.json', root)).toBeNull();
expect(
resolvePackagedRendererPath('goose-app://goose/assets%5C..%5Csettings.json', root)
).toBeNull();
});

it('returns content types for renderer assets', () => {
expect(rendererContentType('/tmp/index.html')).toBe('text/html; charset=utf-8');
expect(rendererContentType('/tmp/assets/index.js')).toBe('text/javascript; charset=utf-8');
expect(rendererContentType('/tmp/assets/index.css')).toBe('text/css; charset=utf-8');
expect(rendererContentType('/tmp/assets/font.woff2')).toBe('font/woff2');
expect(rendererContentType('/tmp/assets/file.bin')).toBe('application/octet-stream');
});
});
92 changes: 92 additions & 0 deletions ui/desktop/src/appProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import path from 'node:path';

export const PACKAGED_RENDERER_PROTOCOL = 'goose-app';
export const PACKAGED_RENDERER_HOST = 'goose';
export const PACKAGED_RENDERER_ORIGIN = `${PACKAGED_RENDERER_PROTOCOL}://${PACKAGED_RENDERER_HOST}`;
export const GOOSE_SESSION_PARTITION = 'persist:goose';

export function packagedRendererUrl(): URL {
return new URL(`${PACKAGED_RENDERER_ORIGIN}/index.html`);
}

function containsTraversalSegment(requestUrl: string): boolean {
return /(?:^|\/|%2f|\\|%5c)(?:\.\.|%2e%2e)(?:$|\/|%2f|\\|%5c|\?|#)/i.test(requestUrl);
}

export function resolvePackagedRendererPath(
requestUrl: string,
rendererRoot: string
): string | null {
if (containsTraversalSegment(requestUrl)) {
return null;
}

let url: URL;
try {
url = new URL(requestUrl);
} catch {
return null;
}

if (
url.protocol !== `${PACKAGED_RENDERER_PROTOCOL}:` ||
url.hostname !== PACKAGED_RENDERER_HOST
) {
return null;
}

let pathname: string;
try {
pathname = decodeURIComponent(url.pathname);
} catch {
return null;
}

const relativePath = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
if (!relativePath || relativePath.includes('\0') || relativePath.includes('\\')) {
return null;
}

const root = path.resolve(rendererRoot);
const resolvedPath = path.resolve(root, relativePath);
if (resolvedPath !== root && !resolvedPath.startsWith(`${root}${path.sep}`)) {
return null;
}

return resolvedPath;
}

export function rendererContentType(filePath: string): string {
switch (path.extname(filePath).toLowerCase()) {
case '.html':
return 'text/html; charset=utf-8';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.css':
return 'text/css; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
case '.wasm':
return 'application/wasm';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
33 changes: 21 additions & 12 deletions ui/desktop/src/components/settings/app/ExternalBackendSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AlertCircle } from 'lucide-react';
import { ExternalGoosedConfig, defaultSettings } from '../../../utils/settings';
import { defineMessages, useIntl } from '../../../i18n';
import { normalizeAcpHttpBaseUrl } from '../../../acp/url';
import { PACKAGED_RENDERER_ORIGIN } from '../../../appProtocol';

const i18n = defineMessages({
title: {
Expand Down Expand Up @@ -34,6 +35,11 @@ const i18n = defineMessages({
defaultMessage:
'Enter the HTTP(S) base URL. Goose checks /status and connects to /acp under this base.',
},
allowedOriginHelp: {
id: 'externalBackendSection.allowedOriginHelp',
defaultMessage:
'Packaged Goose Desktop connects from origin {origin}. Start external goose serve with --allowed-origin {origin}.',
},
secretKey: {
id: 'externalBackendSection.secretKey',
defaultMessage: 'Secret Key',
Expand All @@ -56,12 +62,12 @@ const i18n = defineMessages({
},
certFingerprintHelp: {
id: 'externalBackendSection.certFingerprintHelp',
defaultMessage: 'Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU).',
defaultMessage:
'Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU).',
},
restartNote: {
id: 'externalBackendSection.restartNote',
defaultMessage:
'Changes apply to new chat windows. Restart Goose to update existing windows.',
defaultMessage: 'Changes apply to new chat windows. Restart Goose to update existing windows.',
},
urlProtocolError: {
id: 'externalBackendSection.urlProtocolError',
Expand All @@ -77,7 +83,8 @@ const i18n = defineMessages({
},
urlBaseError: {
id: 'externalBackendSection.urlBaseError',
defaultMessage: 'URL must be the backend base URL before /acp, without query parameters or fragments',
defaultMessage:
'URL must be the backend base URL before /acp, without query parameters or fragments',
},
});

Expand All @@ -95,10 +102,7 @@ export default function ExternalBackendSection() {
loadSettings();
}, []);

const validateUrl = (
value: string,
certFingerprint = config.certFingerprint
): boolean => {
const validateUrl = (value: string, certFingerprint = config.certFingerprint): boolean => {
if (!value) {
setUrlError(null);
return true;
Expand Down Expand Up @@ -175,14 +179,14 @@ export default function ExternalBackendSection() {
<Card className="pb-2">
<CardHeader className="pb-0">
<CardTitle>{intl.formatMessage(i18n.title)}</CardTitle>
<CardDescription>
{intl.formatMessage(i18n.description)}
</CardDescription>
<CardDescription>{intl.formatMessage(i18n.description)}</CardDescription>
</CardHeader>
<CardContent className="pt-4 space-y-4 px-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-primary text-xs">{intl.formatMessage(i18n.useExternalServer)}</h3>
<h3 className="text-text-primary text-xs">
{intl.formatMessage(i18n.useExternalServer)}
</h3>
<p className="text-xs text-text-secondary max-w-md mt-[2px]">
{intl.formatMessage(i18n.useExternalServerDescription)}
</p>
Expand Down Expand Up @@ -222,6 +226,11 @@ export default function ExternalBackendSection() {
<p className="text-xs text-text-secondary">
{intl.formatMessage(i18n.serverUrlHelp)}
</p>
<p className="text-xs text-text-secondary">
{intl.formatMessage(i18n.allowedOriginHelp, {
origin: PACKAGED_RENDERER_ORIGIN,
})}
</p>
</div>

<div className="space-y-2">
Expand Down
Loading
Loading