fix(kosync): reject non-KOReader-Sync server URLs on connect (#4692) (#4711)

A wrong Server URL can land on the host's static web UI, which answers
200 OK with an HTML page. connect() treated any 2xx from /users/auth (or
/users/create) as a successful login, so the user was silently
"connected" to an endpoint that can never sync: pulls report 0% and
pushes fail with no error surfaced. This is the root of the symptom in
#4692 (KOReader sync to a Grimmory/Booklore server failing on Android).

Validate that the auth/registration response is an actual KOReader Sync
JSON object (a real server replies e.g. {"authorized":"OK"}; an HTML
page fails JSON parsing) and otherwise return a clear
"Not a KOReader Sync server. Check the Server URL." message.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 01:51:15 +08:00
committed by GitHub
parent 15f1838781
commit 316ca3c941
2 changed files with 112 additions and 0 deletions
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KOSyncSettings } from '@/types/settings';
// The LAN-server branch of KOSyncClient.request uses window.fetch (mocked
// per-test); the Tauri HTTP plugin is never invoked here, so stub the import
// to keep the unit environment free of Tauri internals.
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
const makeConfig = (overrides: Partial<KOSyncSettings> = {}): KOSyncSettings => ({
enabled: true,
// A LAN address makes request() take the direct window.fetch path.
serverUrl: 'http://192.168.1.50',
username: 'alice',
userkey: '',
password: '',
deviceId: 'device-1',
deviceName: 'Readest',
checksumMethod: 'binary',
strategy: 'prompt',
...overrides,
});
type FetchMock = ReturnType<typeof vi.fn>;
const setFetch = (impl: (...args: unknown[]) => unknown): FetchMock => {
const mock = vi.fn(impl) as FetchMock;
vi.stubGlobal('fetch', mock);
window.fetch = mock as unknown as typeof window.fetch;
return mock;
};
// Minimal Response-like object covering the fields KOSyncClient reads.
const htmlPage = (status = 200) => ({
ok: status >= 200 && status < 300,
status,
json: async () => {
throw new SyntaxError('Unexpected token < in JSON');
},
});
const jsonResponse = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
describe('KOSyncClient.connect server validation', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('fails when /users/auth returns 200 with a non-JSON (web UI) page', async () => {
// A wrong Server URL that lands on the server's static web UI returns the
// HTML index page with 200 OK. That must NOT be treated as a successful
// login (it isn't a KOReader sync endpoint).
setFetch(() => htmlPage(200));
const client = new KOSyncClient(makeConfig());
const result = await client.connect('alice', 'secret');
expect(result.success).toBe(false);
});
it('succeeds when /users/auth returns a valid KOReader auth JSON', async () => {
setFetch(() => jsonResponse(200, { authorized: 'OK' }));
const client = new KOSyncClient(makeConfig());
const result = await client.connect('alice', 'secret');
expect(result.success).toBe(true);
});
it('fails when registration (/users/create) returns 200 with a non-JSON page', async () => {
// /users/auth → 401 routes connect() into the create path; a web UI that
// returns 200 HTML there must not be reported as a successful registration.
const mock = setFetch((url: unknown) => {
if (String(url).includes('/users/create')) return htmlPage(200);
return htmlPage(401); // auth fails -> triggers create
});
const client = new KOSyncClient(makeConfig());
const result = await client.connect('alice', 'secret');
expect(result.success).toBe(false);
expect(mock).toHaveBeenCalled();
});
});
@@ -129,6 +129,13 @@ export class KOSyncClient {
});
if (authResponse.ok) {
// A wrong Server URL can land on the host's web UI, which answers 200
// with an HTML page. Only treat the response as a successful login when
// it's an actual KOReader Sync JSON response, otherwise the user is
// silently "connected" to an endpoint that can never sync.
if (!(await this.isKoSyncJsonResponse(authResponse))) {
return { success: false, message: 'Not a KOReader Sync server. Check the Server URL.' };
}
return { success: true, message: 'Login successful.' };
}
@@ -140,6 +147,9 @@ export class KOSyncClient {
});
if (registerResponse.ok) {
if (!(await this.isKoSyncJsonResponse(registerResponse))) {
return { success: false, message: 'Not a KOReader Sync server. Check the Server URL.' };
}
return { success: true, message: 'Registration successful.' };
}
@@ -230,6 +240,17 @@ export class KOSyncClient {
}
}
/**
* A genuine KOReader Sync server replies with a JSON object (e.g.
* `{ "authorized": "OK" }`). A misconfigured Server URL that hits a static
* web UI returns an HTML page instead, which fails JSON parsing — use that to
* tell the two apart.
*/
private async isKoSyncJsonResponse(response: Response): Promise<boolean> {
const data = await response.json().catch(() => null);
return typeof data === 'object' && data !== null;
}
getDocumentDigest(book: Book): string {
if (this.config.checksumMethod === 'filename') {
console.warn('This is not possible anymore, using md5 instead.');