mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(kobo): keep the public port behind a forwarding proxy
A Kobo sends Host without a port, and Tailscale Funnel repeats that Host in X-Forwarded-Host without adding X-Forwarded-Port. buildBaseUrl then skipped APP_URL because forwarded headers were present, so cover, reading state and download links lost the public port and pointed at port 443. The Kobo never reached the server for a download or a thumbnail, and the delivery stayed offered. Use APP_URL's origin whenever no port is given and its host matches, and, when the proxy names a scheme, only if the scheme matches too. Explicit ports, X-Forwarded-Port and hosts or schemes APP_URL does not share keep their previous behaviour. Fixes #2036
This commit is contained in:
@@ -242,11 +242,73 @@ describe('KoboSyncController', () => {
|
||||
expect(resources(controllerWithAppUrl('http://localhost:6263'), koboRequest('192.168.1.67')).image_host).toBe('http://192.168.1.67:3000');
|
||||
});
|
||||
|
||||
it('lets an explicit port or forwarded headers win over APP_URL', () => {
|
||||
it('lets an explicit port, a forwarded port, or a forwarded scheme APP_URL does not share win over APP_URL', () => {
|
||||
const controller = controllerWithAppUrl('http://192.168.1.67:3010');
|
||||
|
||||
expect(resources(controller, koboRequest('192.168.1.67:3000')).image_host).toBe('http://192.168.1.67:3000');
|
||||
expect(resources(controller, koboRequest('192.168.1.67', { 'x-forwarded-proto': 'https' })).image_host).toBe('https://192.168.1.67');
|
||||
expect(resources(controller, koboRequest('192.168.1.67', { 'x-forwarded-host': '192.168.1.67', 'x-forwarded-port': '8080' })).image_host).toBe(
|
||||
'http://192.168.1.67:8080',
|
||||
);
|
||||
});
|
||||
|
||||
describe("behind a forwarding proxy that repeats the Kobo's port-less Host (#2036)", () => {
|
||||
const funnel = { 'x-forwarded-host': 'reader.tail1234.ts.net', 'x-forwarded-proto': 'https', 'x-forwarded-for': '203.0.113.9' };
|
||||
const appUrl = 'https://reader.tail1234.ts.net:8443';
|
||||
|
||||
it('takes the public port from APP_URL, as Tailscale Funnel on a non-standard port needs', () => {
|
||||
const res = resources(controllerWithAppUrl(appUrl), koboRequest('reader.tail1234.ts.net', funnel));
|
||||
|
||||
expect(res.image_host).toBe(appUrl);
|
||||
expect(res.image_url_template).toBe(`${appUrl}/api/v1/kobo/abc/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg`);
|
||||
expect(res.library_sync).toBe(`${appUrl}/api/v1/kobo/abc/v1/library/sync`);
|
||||
expect(res.reading_state).toBe(`${appUrl}/api/v1/kobo/abc/v1/library/{Ids}/state`);
|
||||
expect(res.reading_services_host).toBe(appUrl);
|
||||
});
|
||||
|
||||
it('matches the host without regard to case', () => {
|
||||
expect(
|
||||
resources(controllerWithAppUrl(appUrl), koboRequest('READER.tail1234.ts.net', { ...funnel, 'x-forwarded-host': 'READER.tail1234.ts.net' }))
|
||||
.image_host,
|
||||
).toBe(appUrl);
|
||||
});
|
||||
|
||||
it('uses APP_URL when the proxy forwards only the host', () => {
|
||||
expect(
|
||||
resources(controllerWithAppUrl(appUrl), koboRequest('reader.tail1234.ts.net', { 'x-forwarded-host': 'reader.tail1234.ts.net' })).image_host,
|
||||
).toBe(appUrl);
|
||||
});
|
||||
|
||||
it('keeps the forwarded origin when APP_URL names another host or another scheme', () => {
|
||||
expect(resources(controllerWithAppUrl('https://other.example.com:8443'), koboRequest('reader.tail1234.ts.net', funnel)).image_host).toBe(
|
||||
'https://reader.tail1234.ts.net',
|
||||
);
|
||||
expect(resources(controllerWithAppUrl('http://reader.tail1234.ts.net:8443'), koboRequest('reader.tail1234.ts.net', funnel)).image_host).toBe(
|
||||
'https://reader.tail1234.ts.net',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a client that sends the port itself alone', () => {
|
||||
const withPort = { ...funnel, 'x-forwarded-host': 'reader.tail1234.ts.net:9443' };
|
||||
|
||||
expect(resources(controllerWithAppUrl(appUrl), koboRequest('reader.tail1234.ts.net:9443', withPort)).image_host).toBe(
|
||||
'https://reader.tail1234.ts.net:9443',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds library sync download links from the same origin', async () => {
|
||||
syncService.getDelta.mockResolvedValue({ entitlements: [], hasMore: false, syncToken: 'token' });
|
||||
settingsService.getSettings.mockResolvedValue({ storeSync: false });
|
||||
|
||||
await controllerWithAppUrl(appUrl).librarySync(
|
||||
{ deviceId: 5, deviceToken: 'abc' } as never,
|
||||
{ id: 9 } as never,
|
||||
koboRequest('reader.tail1234.ts.net', funnel) as never,
|
||||
makeReply() as never,
|
||||
);
|
||||
|
||||
expect(syncService.getDelta).toHaveBeenCalledWith(9, 5, 'abc', appUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('builds library sync download links from the same origin', async () => {
|
||||
|
||||
@@ -79,12 +79,14 @@ function buildBaseUrl(req: FastifyRequest, appUrl: string): string {
|
||||
if (port) {
|
||||
const isDefault = (proto === 'http' && port === '80') || (proto === 'https' && port === '443');
|
||||
if (!isDefault) host = host + ':' + port;
|
||||
} else if (!hasForwarded) {
|
||||
const appOrigin = appOriginForHost(appUrl, host);
|
||||
} else {
|
||||
const appOrigin = appOriginForHost(appUrl, host, fwdProto);
|
||||
if (appOrigin) return appOrigin;
|
||||
const localPort = req.socket?.localPort;
|
||||
const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443);
|
||||
if (localPort && !isDefault) host = host + ':' + String(localPort);
|
||||
if (!hasForwarded) {
|
||||
const localPort = req.socket?.localPort;
|
||||
const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443);
|
||||
if (localPort && !isDefault) host = host + ':' + String(localPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +95,18 @@ function buildBaseUrl(req: FastifyRequest, appUrl: string): string {
|
||||
|
||||
/**
|
||||
* A Kobo leaves the port out of its Host header, and behind a published port mapping the port the
|
||||
* server listens on is not the one the Kobo reached (#1281). APP_URL names the reachable origin, so
|
||||
* it is used when it is for the same host; any other host keeps the listening port.
|
||||
* server listens on is not the one the Kobo reached (#1281). A forwarding proxy such as Tailscale
|
||||
* Funnel repeats that port-less Host in X-Forwarded-Host and adds no X-Forwarded-Port, so the
|
||||
* public port is lost the same way (#2036). APP_URL names the reachable origin, so it is used when
|
||||
* it is for the same host and, if the proxy named a scheme, the same scheme; any other host keeps
|
||||
* the listening port.
|
||||
*/
|
||||
function appOriginForHost(appUrl: string, host: string): string | null {
|
||||
function appOriginForHost(appUrl: string, host: string, forwardedProto: string | undefined): string | null {
|
||||
try {
|
||||
const url = new URL(appUrl);
|
||||
return url.hostname.toLowerCase() === host.toLowerCase() ? url.origin : null;
|
||||
if (url.hostname.toLowerCase() !== host.toLowerCase()) return null;
|
||||
if (forwardedProto && url.protocol !== `${forwardedProto.toLowerCase()}:`) return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user