Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/ui-tars/src/main/services/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function registerSettingsHandlers() {
*/
ipcMain.handle('setting:importPresetFromUrl', async (_, url, autoUpdate) => {
try {
await SettingStore.validatePresetUrl(url);
const newSettings = await SettingStore.fetchPresetFromUrl(url);
SettingStore.setStore({
...newSettings,
Expand Down
126 changes: 126 additions & 0 deletions apps/ui-tars/src/main/store/preset-url-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Target path: apps/ui-tars/src/main/store/preset-url-validation.test.ts
*/
import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('@main/logger', () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));

import { SettingStore } from './setting';

const PUBLIC_IP = '93.184.216.34';

const buildRedirectResponse = (location: string): Response => {
if (typeof Response !== 'undefined') {
return new Response('', {
status: 302,
headers: { location },
});
}
return {
status: 302,
ok: false,
headers: { get: () => location },
body: { cancel: () => {} },
} as unknown as Response;
};

const buildOkResponse = (contentLength: number): Response => {
if (typeof Response !== 'undefined') {
return new Response('ok', {
status: 200,
headers: { 'content-length': String(contentLength) },
});
}
return {
status: 200,
ok: true,
headers: {
get: (key: string) =>
key.toLowerCase() === 'content-length' ? String(contentLength) : null,
},
body: null,
text: async () => 'ok',
} as unknown as Response;
};

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('SettingStore.validatePresetUrl', () => {
it('rejects non-https URLs', async () => {
await expect(
SettingStore.validatePresetUrl('http://example.com/preset.yaml'),
).rejects.toThrow('HTTPS');
});

it('rejects localhost and localhost.', async () => {
await expect(
SettingStore.validatePresetUrl('https://localhost/preset.yaml'),
).rejects.toThrow('not allowed');
await expect(
SettingStore.validatePresetUrl('https://localhost./preset.yaml'),
).rejects.toThrow('not allowed');
});

it('rejects private IPv4 and loopback', async () => {
await expect(
SettingStore.validatePresetUrl('https://127.0.0.1/preset.yaml'),
).rejects.toThrow('private or local');
await expect(
SettingStore.validatePresetUrl('https://192.168.0.1/preset.yaml'),
).rejects.toThrow('private or local');
});

it('rejects IPv6 link-local addresses', async () => {
await expect(
SettingStore.validatePresetUrl('https://[fe80::1]/preset.yaml'),
).rejects.toThrow('private or local');
});

it('accepts public IPv4 host', async () => {
const url = await SettingStore.validatePresetUrl(
`https://${PUBLIC_IP}/preset.yaml`,
);
expect(url.hostname).toBe(PUBLIC_IP);
});
});

describe('SettingStore.fetchPresetFromUrl', () => {
it('rejects redirects to private hosts', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
buildRedirectResponse('https://127.0.0.1/preset.yaml'),
),
);

await expect(
SettingStore.fetchPresetFromUrl(`https://${PUBLIC_IP}/preset.yaml`),
).rejects.toThrow('private or local');
});

it('rejects responses exceeding size limits', async () => {
const limit =
(SettingStore as unknown as { PRESET_MAX_BYTES?: number })
.PRESET_MAX_BYTES ?? 1024 * 1024;
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(buildOkResponse(limit + 1)),
);

await expect(
SettingStore.fetchPresetFromUrl(`https://${PUBLIC_IP}/preset.yaml`),
).rejects.toThrow('too large');
});
});
189 changes: 177 additions & 12 deletions apps/ui-tars/src/main/store/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/
import ElectronStore from 'electron-store';
import yaml from 'js-yaml';
import dns from 'dns/promises';
import net from 'net';

import * as env from '@main/env';
import { logger } from '@main/logger';
Expand Down Expand Up @@ -33,6 +35,8 @@ export const DEFAULT_SETTING: LocalStore = {
};

export class SettingStore {
private static readonly PRESET_FETCH_TIMEOUT_MS = 10_000;
private static readonly PRESET_MAX_BYTES = 1024 * 1024;
private static instance: ElectronStore<LocalStore>;

public static getInstance(): ElectronStore<LocalStore> {
Expand Down Expand Up @@ -86,22 +90,138 @@ export class SettingStore {
SettingStore.getInstance().openInEditor();
}

private static isLocalHostname(hostname: string): boolean {
const lower = hostname.toLowerCase().replace(/\.+$/, '');
return lower === 'localhost' || lower.endsWith('.localhost');
}

private static isPrivateOrLocalIp(ip: string): boolean {
const normalizedIp = ip.split('%')[0];
const version = net.isIP(normalizedIp);
if (version === 4) {
const parts = normalizedIp.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
return false;
}
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
return false;
}

if (version === 6) {
const normalized = normalizedIp.toLowerCase();
if (normalized === '::1' || normalized === '::') return true;
if (normalized.startsWith('fe80:')) return true;
if (normalized.startsWith('fc') || normalized.startsWith('fd'))
return true;
if (normalized.startsWith('::ffff:')) {
const mapped = normalized.replace('::ffff:', '');
return SettingStore.isPrivateOrLocalIp(mapped);
}
}

return false;
}

public static async validatePresetUrl(input: string): Promise<URL> {
let parsed: URL;
try {
parsed = new URL(input);
} catch {
throw new Error('Invalid preset URL');
}

if (parsed.protocol !== 'https:') {
throw new Error('Preset URL must use HTTPS');
}

if (!parsed.hostname || SettingStore.isLocalHostname(parsed.hostname)) {
throw new Error('Preset URL host is not allowed');
}

try {
const rawHost = parsed.hostname;
const bracketlessHost =
rawHost.startsWith('[') && rawHost.endsWith(']')
? rawHost.slice(1, -1)
: rawHost;
const normalizedHost = bracketlessHost.split('%')[0];
const ipVersion = net.isIP(normalizedHost);
const addresses = ipVersion
? [{ address: normalizedHost }]
: await dns.lookup(normalizedHost, { all: true });

if (!addresses.length) {
throw new Error('Preset URL host cannot be resolved');
}

for (const { address } of addresses) {
if (SettingStore.isPrivateOrLocalIp(address)) {
throw new Error('Preset URL resolves to a private or local address');
}
}
} catch (error) {
throw new Error(
`Preset URL host validation failed: ${error instanceof Error ? error.message : String(error)}`,
);
}

return parsed;
}

private static async readResponseTextWithLimit(
response: Response,
maxBytes: number,
): Promise<string> {
const contentLength = response.headers.get('content-length');
if (contentLength) {
const size = Number(contentLength);
if (Number.isFinite(size) && size > maxBytes) {
throw new Error('Preset response is too large');
}
}

if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
throw new Error('Preset response is too large');
}
return text;
}

const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let received = 0;
let result = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
received += value.byteLength;
if (received > maxBytes) {
throw new Error('Preset response is too large');
}
result += decoder.decode(value, { stream: true });
}

result += decoder.decode();
return result;
}

public static async importPresetFromUrl(
url: string,
autoUpdate = false,
): Promise<void> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch preset: ${response.status}`);
}

const yamlText = await response.text();
const preset = yaml.load(yamlText);
const validatedPreset = validatePreset(preset);

const newSettings = await SettingStore.fetchPresetFromUrl(url);
SettingStore.setStore({
...validatedPreset,
...newSettings,
presetSource: {
type: 'remote',
url,
Expand Down Expand Up @@ -129,10 +249,55 @@ export class SettingStore {
}
}

private static async fetchPresetResponse(
validatedUrl: URL,
): Promise<Response> {
let currentUrl = validatedUrl;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
SettingStore.PRESET_FETCH_TIMEOUT_MS,
);
let response: Response;
try {
response = await fetch(currentUrl.toString(), {
signal: controller.signal,
redirect: 'manual',
});
} finally {
clearTimeout(timeoutId);
}

if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
response.body?.cancel();
if (!location) {
throw new Error('Preset URL redirect missing location');
}
const nextUrl = new URL(location, currentUrl);
currentUrl = await SettingStore.validatePresetUrl(nextUrl.toString());
continue;
}

return response;
}

throw new Error('Too many redirects when fetching preset');
}

public static async fetchPresetFromUrl(url: string): Promise<LocalStore> {
try {
const response = await fetch(url);
const yamlContent = await response.text();
const validatedUrl = await SettingStore.validatePresetUrl(url);
const response = await SettingStore.fetchPresetResponse(validatedUrl);
if (!response.ok) {
throw new Error(`Failed to fetch preset: ${response.status}`);
}

const yamlContent = await SettingStore.readResponseTextWithLimit(
response,
SettingStore.PRESET_MAX_BYTES,
);
return await this.importPresetFromText(yamlContent);
} catch (error) {
logger.error('Failed to fetch preset from URL:', error);
Expand Down