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
2 changes: 2 additions & 0 deletions dsh-desktop-next/src/lan-https-ca-route.ts
Original file line number Diff line number Diff line change
@@ -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'
28 changes: 28 additions & 0 deletions dsh-desktop-next/src/lan-https-ingress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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. */
Expand Down Expand Up @@ -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<string>
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions dsh-desktop-next/src/lan-https-runtime.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
})
Expand Down
76 changes: 74 additions & 2 deletions dsh-desktop-next/tests/lan-https-ingress.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function options(targetPort: number, overrides: Partial<LanHttpsIngressOptions>
requestedPort: 0,
key: leafPrivateKey,
cert: leafCertificate,
caCertificate,
caFingerprint: createHash('sha256').update(caCertificate).digest('hex'),
allowedAddresses: [LAN_ADDRESS],
...overrides,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions dsh-plugin-desktop-beta/src/lan-https-ca-route.ts
Original file line number Diff line number Diff line change
@@ -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'
28 changes: 28 additions & 0 deletions dsh-plugin-desktop-beta/src/lan-https-ingress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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. */
Expand Down Expand Up @@ -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<string>
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions dsh-plugin-desktop-beta/src/lan-https-runtime.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
})
Expand Down
76 changes: 74 additions & 2 deletions dsh-plugin-desktop-beta/tests/lan-https-ingress.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function options(targetPort: number, overrides: Partial<LanHttpsIngressOptions>
requestedPort: 0,
key: leafPrivateKey,
cert: leafCertificate,
caCertificate,
caFingerprint: createHash('sha256').update(caCertificate).digest('hex'),
allowedAddresses: [LAN_ADDRESS],
...overrides,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand Down