From 1f1ee2293659bee8770c36f4c2ad2a4ee13d3705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=B9=E5=92=8C=E5=A5=B9=E7=9A=84=E7=8C=AB?= Date: Thu, 5 Nov 2026 14:55:12 +0800 Subject: [PATCH] fix(desktop): serve LAN CA downloads at HTTPS edge --- dsh-desktop-next/src/lan-https-ca-route.ts | 2 + dsh-desktop-next/src/lan-https-ingress.ts | 28 +++++++ dsh-desktop-next/src/lan-https-runtime.ts | 5 +- .../tests/lan-https-ingress.spec.ts | 76 ++++++++++++++++++- .../src/lan-https-ca-route.ts | 2 + .../src/lan-https-ingress.ts | 28 +++++++ .../src/lan-https-runtime.ts | 5 +- .../tests/lan-https-ingress.spec.ts | 76 ++++++++++++++++++- 8 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 dsh-desktop-next/src/lan-https-ca-route.ts create mode 100644 dsh-plugin-desktop-beta/src/lan-https-ca-route.ts diff --git a/dsh-desktop-next/src/lan-https-ca-route.ts b/dsh-desktop-next/src/lan-https-ca-route.ts new file mode 100644 index 00000000000..45cb8213dd3 --- /dev/null +++ b/dsh-desktop-next/src/lan-https-ca-route.ts @@ -0,0 +1,2 @@ +/** Public, exact CA download path served by the LAN HTTPS edge. */ +export const DESKTOP_LAN_HTTPS_CA_PATH = '/.well-known/dsh-desktop-ca.crt' diff --git a/dsh-desktop-next/src/lan-https-ingress.ts b/dsh-desktop-next/src/lan-https-ingress.ts index bc703d86271..b5dfc77dd6e 100644 --- a/dsh-desktop-next/src/lan-https-ingress.ts +++ b/dsh-desktop-next/src/lan-https-ingress.ts @@ -4,6 +4,7 @@ import { request as requestHttp, type IncomingHttpHeaders, type IncomingMessage, import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https' import { isIPv4, Socket } from 'node:net' import type { Duplex } from 'node:stream' +import { DESKTOP_LAN_HTTPS_CA_PATH } from './lan-https-ca-route.ts' import { MAX_HEADER_BYTES } from './browser-sessions.ts' import { NATIVE_ACCESS_HEADER as DESKTOP_RENDERER_ACCESS_HEADER } from './desktop-contract.ts' @@ -20,6 +21,8 @@ export interface LanHttpsIngressOptions { readonly requestedPort: number readonly key: string | Buffer readonly cert: string | Buffer + /** Public CA PEM published only after LAN Host validation. */ + readonly caCertificate: string | Buffer /** Stable identity of the CA shown by the Desktop trust flow. */ readonly caFingerprint: string /** Startup-sampled LAN IPv4 literals accepted in the external Host header. */ @@ -182,6 +185,7 @@ export class LanHttpsIngress { private readonly requestedPort: number private readonly key: string | Buffer private readonly cert: string | Buffer + private readonly caCertificate: string | Buffer private readonly caFingerprint: string private readonly addresses: readonly string[] private readonly allowedAddresses: ReadonlySet @@ -195,6 +199,7 @@ export class LanHttpsIngress { this.requestedPort = exactPort(options.requestedPort, true, 'LAN HTTPS requested port') this.key = copyTlsValue(options.key, 'private key') this.cert = copyTlsValue(options.cert, 'certificate') + this.caCertificate = copyTlsValue(options.caCertificate, 'CA certificate') const fingerprint = options.caFingerprint.trim() if (fingerprint.length === 0) throw new TypeError('dsh-desktop-next: LAN HTTPS CA fingerprint must not be empty') this.caFingerprint = fingerprint @@ -226,12 +231,35 @@ export class LanHttpsIngress { socket.once('close', () => this.sockets.delete(socket)) } + private serveCaCertificate(request: IncomingMessage, response: ServerResponse): void { + request.resume() + const contentLength = Buffer.byteLength(this.caCertificate) + response.setHeader('cache-control', 'no-store') + response.setHeader('content-type', 'application/x-x509-ca-cert') + response.setHeader('content-disposition', 'attachment; filename="dsh-desktop-ca.crt"') + response.setHeader('x-content-type-options', 'nosniff') + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.statusCode = 405 + response.setHeader('allow', 'GET, HEAD') + response.setHeader('content-length', 0) + response.end() + return + } + response.setHeader('content-length', contentLength) + response.statusCode = 200 + response.end(request.method === 'HEAD' ? undefined : this.caCertificate) + } + private proxyHttp(request: IncomingMessage, response: ServerResponse): void { if (!permits(request.headers, this.allowedAddresses)) { request.resume() reject(response, 403, 'forbidden') return } + if (request.url === DESKTOP_LAN_HTTPS_CA_PATH) { + this.serveCaCertificate(request, response) + return + } const proxyRequest = requestHttp({ host: TARGET_HOST, diff --git a/dsh-desktop-next/src/lan-https-runtime.ts b/dsh-desktop-next/src/lan-https-runtime.ts index 8eed2f7510d..d3b25b5279a 100644 --- a/dsh-desktop-next/src/lan-https-runtime.ts +++ b/dsh-desktop-next/src/lan-https-runtime.ts @@ -1,15 +1,13 @@ /** Generation-scoped owner for the optional Desktop LAN HTTPS edge. */ import type { DesktopLanHttpsCertificate } from './lan-https-certificate.ts' +export { DESKTOP_LAN_HTTPS_CA_PATH } from './lan-https-ca-route.ts' import { LanHttpsIngress, type LanHttpsIngressSnapshot, type LanHttpsIngressState, } from './lan-https-ingress.ts' -/** Public CA download path served through the Desktop Web carrier and HTTPS edge. */ -export const DESKTOP_LAN_HTTPS_CA_PATH = '/.well-known/dsh-desktop-ca.crt' - /** Renderer-safe state for an available, inactive, or failed LAN HTTPS edge. */ export interface DesktopLanHttpsRuntimeSnapshot { readonly state: LanHttpsIngressState @@ -112,6 +110,7 @@ export class DesktopLanHttpsRuntime { requestedPort: this.requestedPort, key: certificate.key, cert: certificate.cert, + caCertificate: certificate.caCertificate, caFingerprint: certificate.caFingerprint, allowedAddresses: certificate.addresses, }) diff --git a/dsh-desktop-next/tests/lan-https-ingress.spec.ts b/dsh-desktop-next/tests/lan-https-ingress.spec.ts index ac6b1893744..f85207087c6 100644 --- a/dsh-desktop-next/tests/lan-https-ingress.spec.ts +++ b/dsh-desktop-next/tests/lan-https-ingress.spec.ts @@ -95,6 +95,7 @@ function options(targetPort: number, overrides: Partial requestedPort: 0, key: leafPrivateKey, cert: leafCertificate, + caCertificate, caFingerprint: createHash('sha256').update(caCertificate).digest('hex'), allowedAddresses: [LAN_ADDRESS], ...overrides, @@ -224,6 +225,77 @@ describe('LAN HTTPS ingress', () => { expect(chunks.join('')).toBe('data: first\n\ndata: second\n\n') }) + it('serves the CA download from the HTTPS edge without proxying it to the loopback Host', async () => { + let backendHits = 0 + const backend = createServer((_request, response) => { + backendHits += 1 + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('not found') + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + + const result = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt') + + expect(result.statusCode).toBe(200) + expect(result.headers).toMatchObject({ + 'content-type': 'application/x-x509-ca-cert', + 'content-disposition': 'attachment; filename="dsh-desktop-ca.crt"', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'content-length': String(Buffer.byteLength(caCertificate)), + }) + expect(result.body.toString('utf8')).toBe(caCertificate) + expect(backendHits).toBe(0) + }) + + it('supports HEAD but rejects unsafe methods for the CA download without proxying', async () => { + let backendHits = 0 + const backend = createServer((_request, response) => { + backendHits += 1 + response.end('unexpected') + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + + const head = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { method: 'HEAD' }) + const post = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { method: 'POST', body: 'ignored' }) + + expect(head.statusCode).toBe(200) + expect(head.headers).toMatchObject({ + 'content-type': 'application/x-x509-ca-cert', + 'content-length': String(Buffer.byteLength(caCertificate)), + }) + expect(head.body).toEqual(Buffer.alloc(0)) + expect(post.statusCode).toBe(405) + expect(post.headers.allow).toBe('GET, HEAD') + expect(post.headers['content-length']).toBe('0') + expect(post.body).toEqual(Buffer.alloc(0)) + expect(backendHits).toBe(0) + }) + + it('only handles the exact CA path at the HTTPS edge', async () => { + const observedPaths: string[] = [] + const backend = createServer((request, response) => { + observedPaths.push(request.url ?? '') + response.end(`upstream:${request.url ?? ''}`) + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + const paths = [ + '/.well-known/dsh-desktop-ca.crt?download=1', + '/.well-known%2Fdsh-desktop-ca.crt', + '/.well-known/dsh-desktop-ca.crt/', + ] + + for (const path of paths) { + const result = await httpsCall(ingress, path) + expect(result.statusCode).toBe(200) + expect(result.body.toString('utf8')).toBe(`upstream:${path}`) + } + expect(observedPaths).toEqual(paths) + }) + it('streams a large request body and preserves browser authority while removing spoofable identity headers', async () => { let receivedHeaders: IncomingHttpHeaders | undefined const backend = createServer((request, response) => { @@ -310,8 +382,8 @@ describe('LAN HTTPS ingress', () => { auxiliaryServers.push(backend) const ingress = await startIngress(await listen(backend)) - const wrongHost = await httpsCall(ingress, '/', { headers: { host: '192.168.50.99' } }) - const crossSite = await httpsCall(ingress, '/', { headers: { 'sec-fetch-site': 'cross-site' } }) + const wrongHost = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { headers: { host: '192.168.50.99' } }) + const crossSite = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { headers: { 'sec-fetch-site': 'cross-site' } }) expect(wrongHost.statusCode).toBe(403) expect(wrongHost.body.toString('utf8')).toBe('forbidden') diff --git a/dsh-plugin-desktop-beta/src/lan-https-ca-route.ts b/dsh-plugin-desktop-beta/src/lan-https-ca-route.ts new file mode 100644 index 00000000000..45cb8213dd3 --- /dev/null +++ b/dsh-plugin-desktop-beta/src/lan-https-ca-route.ts @@ -0,0 +1,2 @@ +/** Public, exact CA download path served by the LAN HTTPS edge. */ +export const DESKTOP_LAN_HTTPS_CA_PATH = '/.well-known/dsh-desktop-ca.crt' diff --git a/dsh-plugin-desktop-beta/src/lan-https-ingress.ts b/dsh-plugin-desktop-beta/src/lan-https-ingress.ts index 645a55bb9fa..a817cfb57b8 100644 --- a/dsh-plugin-desktop-beta/src/lan-https-ingress.ts +++ b/dsh-plugin-desktop-beta/src/lan-https-ingress.ts @@ -4,6 +4,7 @@ import { request as requestHttp, type IncomingHttpHeaders, type IncomingMessage, import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https' import { isIPv4, Socket } from 'node:net' import type { Duplex } from 'node:stream' +import { DESKTOP_LAN_HTTPS_CA_PATH } from './lan-https-ca-route.ts' import { MAX_HEADER_BYTES } from './browser-sessions.ts' import { NATIVE_ACCESS_HEADER as DESKTOP_RENDERER_ACCESS_HEADER } from './desktop-contract.ts' @@ -20,6 +21,8 @@ export interface LanHttpsIngressOptions { readonly requestedPort: number readonly key: string | Buffer readonly cert: string | Buffer + /** Public CA PEM published only after LAN Host validation. */ + readonly caCertificate: string | Buffer /** Stable identity of the CA shown by the Desktop trust flow. */ readonly caFingerprint: string /** Startup-sampled LAN IPv4 literals accepted in the external Host header. */ @@ -182,6 +185,7 @@ export class LanHttpsIngress { private readonly requestedPort: number private readonly key: string | Buffer private readonly cert: string | Buffer + private readonly caCertificate: string | Buffer private readonly caFingerprint: string private readonly addresses: readonly string[] private readonly allowedAddresses: ReadonlySet @@ -195,6 +199,7 @@ export class LanHttpsIngress { this.requestedPort = exactPort(options.requestedPort, true, 'LAN HTTPS requested port') this.key = copyTlsValue(options.key, 'private key') this.cert = copyTlsValue(options.cert, 'certificate') + this.caCertificate = copyTlsValue(options.caCertificate, 'CA certificate') const fingerprint = options.caFingerprint.trim() if (fingerprint.length === 0) throw new TypeError('dsh-plugin-desktop-beta: LAN HTTPS CA fingerprint must not be empty') this.caFingerprint = fingerprint @@ -226,12 +231,35 @@ export class LanHttpsIngress { socket.once('close', () => this.sockets.delete(socket)) } + private serveCaCertificate(request: IncomingMessage, response: ServerResponse): void { + request.resume() + const contentLength = Buffer.byteLength(this.caCertificate) + response.setHeader('cache-control', 'no-store') + response.setHeader('content-type', 'application/x-x509-ca-cert') + response.setHeader('content-disposition', 'attachment; filename="dsh-desktop-ca.crt"') + response.setHeader('x-content-type-options', 'nosniff') + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.statusCode = 405 + response.setHeader('allow', 'GET, HEAD') + response.setHeader('content-length', 0) + response.end() + return + } + response.setHeader('content-length', contentLength) + response.statusCode = 200 + response.end(request.method === 'HEAD' ? undefined : this.caCertificate) + } + private proxyHttp(request: IncomingMessage, response: ServerResponse): void { if (!permits(request.headers, this.allowedAddresses)) { request.resume() reject(response, 403, 'forbidden') return } + if (request.url === DESKTOP_LAN_HTTPS_CA_PATH) { + this.serveCaCertificate(request, response) + return + } const proxyRequest = requestHttp({ host: TARGET_HOST, diff --git a/dsh-plugin-desktop-beta/src/lan-https-runtime.ts b/dsh-plugin-desktop-beta/src/lan-https-runtime.ts index 5ff59f77f69..feaaa0c4929 100644 --- a/dsh-plugin-desktop-beta/src/lan-https-runtime.ts +++ b/dsh-plugin-desktop-beta/src/lan-https-runtime.ts @@ -1,15 +1,13 @@ /** Generation-scoped owner for the optional Desktop LAN HTTPS edge. */ import type { DesktopLanHttpsCertificate } from './lan-https-certificate.ts' +export { DESKTOP_LAN_HTTPS_CA_PATH } from './lan-https-ca-route.ts' import { LanHttpsIngress, type LanHttpsIngressSnapshot, type LanHttpsIngressState, } from './lan-https-ingress.ts' -/** Public CA download path served through the Desktop Web carrier and HTTPS edge. */ -export const DESKTOP_LAN_HTTPS_CA_PATH = '/.well-known/dsh-desktop-ca.crt' - /** Renderer-safe state for an available, inactive, or failed LAN HTTPS edge. */ export interface DesktopLanHttpsRuntimeSnapshot { readonly state: LanHttpsIngressState @@ -112,6 +110,7 @@ export class DesktopLanHttpsRuntime { requestedPort: this.requestedPort, key: certificate.key, cert: certificate.cert, + caCertificate: certificate.caCertificate, caFingerprint: certificate.caFingerprint, allowedAddresses: certificate.addresses, }) diff --git a/dsh-plugin-desktop-beta/tests/lan-https-ingress.spec.ts b/dsh-plugin-desktop-beta/tests/lan-https-ingress.spec.ts index ac6b1893744..f85207087c6 100644 --- a/dsh-plugin-desktop-beta/tests/lan-https-ingress.spec.ts +++ b/dsh-plugin-desktop-beta/tests/lan-https-ingress.spec.ts @@ -95,6 +95,7 @@ function options(targetPort: number, overrides: Partial requestedPort: 0, key: leafPrivateKey, cert: leafCertificate, + caCertificate, caFingerprint: createHash('sha256').update(caCertificate).digest('hex'), allowedAddresses: [LAN_ADDRESS], ...overrides, @@ -224,6 +225,77 @@ describe('LAN HTTPS ingress', () => { expect(chunks.join('')).toBe('data: first\n\ndata: second\n\n') }) + it('serves the CA download from the HTTPS edge without proxying it to the loopback Host', async () => { + let backendHits = 0 + const backend = createServer((_request, response) => { + backendHits += 1 + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('not found') + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + + const result = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt') + + expect(result.statusCode).toBe(200) + expect(result.headers).toMatchObject({ + 'content-type': 'application/x-x509-ca-cert', + 'content-disposition': 'attachment; filename="dsh-desktop-ca.crt"', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'content-length': String(Buffer.byteLength(caCertificate)), + }) + expect(result.body.toString('utf8')).toBe(caCertificate) + expect(backendHits).toBe(0) + }) + + it('supports HEAD but rejects unsafe methods for the CA download without proxying', async () => { + let backendHits = 0 + const backend = createServer((_request, response) => { + backendHits += 1 + response.end('unexpected') + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + + const head = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { method: 'HEAD' }) + const post = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { method: 'POST', body: 'ignored' }) + + expect(head.statusCode).toBe(200) + expect(head.headers).toMatchObject({ + 'content-type': 'application/x-x509-ca-cert', + 'content-length': String(Buffer.byteLength(caCertificate)), + }) + expect(head.body).toEqual(Buffer.alloc(0)) + expect(post.statusCode).toBe(405) + expect(post.headers.allow).toBe('GET, HEAD') + expect(post.headers['content-length']).toBe('0') + expect(post.body).toEqual(Buffer.alloc(0)) + expect(backendHits).toBe(0) + }) + + it('only handles the exact CA path at the HTTPS edge', async () => { + const observedPaths: string[] = [] + const backend = createServer((request, response) => { + observedPaths.push(request.url ?? '') + response.end(`upstream:${request.url ?? ''}`) + }) + auxiliaryServers.push(backend) + const ingress = await startIngress(await listen(backend)) + const paths = [ + '/.well-known/dsh-desktop-ca.crt?download=1', + '/.well-known%2Fdsh-desktop-ca.crt', + '/.well-known/dsh-desktop-ca.crt/', + ] + + for (const path of paths) { + const result = await httpsCall(ingress, path) + expect(result.statusCode).toBe(200) + expect(result.body.toString('utf8')).toBe(`upstream:${path}`) + } + expect(observedPaths).toEqual(paths) + }) + it('streams a large request body and preserves browser authority while removing spoofable identity headers', async () => { let receivedHeaders: IncomingHttpHeaders | undefined const backend = createServer((request, response) => { @@ -310,8 +382,8 @@ describe('LAN HTTPS ingress', () => { auxiliaryServers.push(backend) const ingress = await startIngress(await listen(backend)) - const wrongHost = await httpsCall(ingress, '/', { headers: { host: '192.168.50.99' } }) - const crossSite = await httpsCall(ingress, '/', { headers: { 'sec-fetch-site': 'cross-site' } }) + const wrongHost = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { headers: { host: '192.168.50.99' } }) + const crossSite = await httpsCall(ingress, '/.well-known/dsh-desktop-ca.crt', { headers: { 'sec-fetch-site': 'cross-site' } }) expect(wrongHost.statusCode).toBe(403) expect(wrongHost.body.toString('utf8')).toBe('forbidden')