Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ INDEXNOW_KEY=
# fail closed (404) when this is unset.
MANAGED_PROXY_EDGE_API_KEY=

MANAGED_PROXY_DOMAIN_CONNECT_ENABLED=false
MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID=_dcpubkeyv1
MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY=

# Public demo project synthetic data generator
DEMO_DATA_ENABLED=false
# S3 prefix containing demo replay folders, for example demo-replays/1/*.json.gz.
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/project/project.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ProxyDomain,
} from './entity'
import { ProxyDomainService } from './proxy-domain.service'
import { ProxyDomainConnectService } from './proxy-domain-connect.service'
import {
ProxyDomainController,
ProxyDomainEdgeController,
Expand Down Expand Up @@ -54,6 +55,7 @@ import { PendingInvitationModule } from '../pending-invitation/pending-invitatio
ProjectsViewsRepository,
GSCService,
ProxyDomainService,
ProxyDomainConnectService,
],
exports: [
ProjectService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common'
import { ProxyDomainController } from './proxy-domain.controller'
import { ProxyDomainConnectService } from './proxy-domain-connect.service'
import { ProxyDomainService } from './proxy-domain.service'
import { ProjectService } from './project.service'

jest.mock('./project.service', () => ({ ProjectService: class {} }))
jest.mock('./proxy-domain.service', () => ({
ProxyDomainService: class {},
PROXY_BASE_DOMAIN: 'proxy.swetrix.org',
}))
jest.mock('../common/constants', () => ({
isValidPID: (pid: string) => pid === 'project-id',
}))
jest.mock('../auth/decorators', () => ({
Auth: () => () => {},
Public: () => () => {},
}))

const project = { id: 'project-id' }
const storedDomain = {
hostname: 't.example.com',
proxyTargetId: 'stored-target',
}

function setup() {
const domains = { findById: jest.fn().mockResolvedValue(storedDomain) }
const projects = {
getFullProject: jest.fn().mockResolvedValue(project),
allowedToManage: jest.fn(),
}
const connect = {
createUrl: jest.fn().mockReturnValue('https://dash.cloudflare.com/signed'),
}
const controller = new ProxyDomainController(
domains as unknown as ProxyDomainService,
projects as unknown as ProjectService,
connect as unknown as ProxyDomainConnectService,
)
return { controller, domains, projects, connect }
}

it('checks management access and uses only the proxy saved in the requested project', async () => {
const { controller, domains, projects, connect } = setup()
await expect(
controller.configureCloudflare('project-id', 'domain-id', 'user-id'),
).resolves.toEqual({ url: 'https://dash.cloudflare.com/signed' })
expect(projects.allowedToManage).toHaveBeenCalledWith(project, 'user-id')
expect(domains.findById).toHaveBeenCalledWith('project-id', 'domain-id')
expect(connect.createUrl).toHaveBeenCalledWith(
'project-id',
storedDomain.hostname,
storedDomain.proxyTargetId,
)
})

it('does not issue a signed URL to a user without management access', async () => {
const { controller, domains, projects, connect } = setup()
projects.allowedToManage.mockImplementation(() => {
throw new ForbiddenException()
})
await expect(
controller.configureCloudflare('project-id', 'domain-id', 'viewer-id'),
).rejects.toThrow(ForbiddenException)
expect(domains.findById).not.toHaveBeenCalled()
expect(connect.createUrl).not.toHaveBeenCalled()
})

it('does not issue a signed URL for a proxy outside the project', async () => {
const { controller, domains, connect } = setup()
domains.findById.mockRejectedValue(new NotFoundException())
await expect(
controller.configureCloudflare('project-id', 'other-domain-id', 'user-id'),
).rejects.toThrow(NotFoundException)
expect(connect.createUrl).not.toHaveBeenCalled()
})
103 changes: 103 additions & 0 deletions backend/apps/cloud/src/project/proxy-domain-connect.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common'
import { createPrivateKey, KeyObject, sign } from 'crypto'
import { parse } from 'tldts'

@Injectable()
export class ProxyDomainConnectService {
private readonly privateKey?: KeyObject
private readonly keyId?: string
private readonly clientOrigin?: string

constructor() {
if (process.env.MANAGED_PROXY_DOMAIN_CONNECT_ENABLED !== 'true') return

try {
const keyId = process.env.MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID
if (!keyId || !/^_[a-z0-9_-]+$/i.test(keyId)) {
throw new Error('Invalid Domain Connect key ID')
}
if (
process.env.MANAGED_PROXY_BASE_DOMAIN &&
process.env.MANAGED_PROXY_BASE_DOMAIN !== 'proxy.swetrix.org'
) {
throw new Error('Domain Connect template requires proxy.swetrix.org')
}
const origin = new URL(process.env.CLIENT_URL || 'https://swetrix.com')
if (origin.protocol !== 'https:') {
throw new Error('Domain Connect requires an HTTPS client URL')
}
const key = createPrivateKey(
(process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY || '').replace(
/\\n/g,
'\n',
),
)
if (
key.asymmetricKeyType !== 'rsa' ||
(key.asymmetricKeyDetails?.modulusLength || 0) < 2048
) {
throw new Error(
'Domain Connect requires an RSA key of at least 2048 bits',
)
}
this.keyId = keyId
this.clientOrigin = origin.origin
this.privateKey = key
} catch {
new Logger(ProxyDomainConnectService.name).warn(
'Cloudflare DNS setup is disabled: check the Domain Connect configuration',
)
}
}

get enabled(): boolean {
return !!this.privateKey
}

createUrl(
projectId: string,
hostname: string,
proxyTargetId: string,
): string {
if (!this.privateKey) {
throw new ServiceUnavailableException(
'Cloudflare DNS setup is unavailable',
)
}

const { domain, subdomain } = parse(hostname)
if (!domain || !subdomain || !/^[a-f0-9]{32}$/.test(proxyTargetId)) {
throw new BadRequestException('Invalid managed proxy domain')
}

const redirect = new URL(
`/projects/settings/${encodeURIComponent(projectId)}`,
this.clientOrigin,
)
redirect.searchParams.set('tab', 'proxy')
redirect.searchParams.set('cloudflare', hostname)

const query = new URLSearchParams({
domain,
host: subdomain,
proxyTargetId,
redirect_uri: redirect.toString(),
}).toString()
const signature = sign(
'RSA-SHA256',
Buffer.from(query),
this.privateKey,
).toString('base64')

return (
'https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/' +
'swetrix.com/services/managed-proxy/apply?' +
`${query}&key=${encodeURIComponent(this.keyId)}&sig=${encodeURIComponent(signature)}`
)
}
}
118 changes: 118 additions & 0 deletions backend/apps/cloud/src/project/proxy-domain-connect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Logger } from '@nestjs/common'
import { generateKeyPairSync, verify } from 'crypto'
import { ProxyDomainConnectService } from './proxy-domain-connect.service'
import template from '../../../../domain-connect/swetrix.com.managed-proxy.json'

const keys = generateKeyPairSync('rsa', { modulusLength: 2048 })
const pem = keys.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const targetId = '0123456789abcdef0123456789abcdef'
const originalEnv = process.env

beforeEach(() => {
process.env = {
...originalEnv,
CLIENT_URL: 'https://swetrix.com',
MANAGED_PROXY_BASE_DOMAIN: 'proxy.swetrix.org',
MANAGED_PROXY_DOMAIN_CONNECT_ENABLED: 'true',
MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID: '_dcpubkeyv1',
MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY: pem,
}
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {})
})

afterEach(() => {
process.env = originalEnv
jest.restoreAllMocks()
})

it('signs only the encoded query, excluding key and sig, and rejects tampering', () => {
const service = new ProxyDomainConnectService()
const url = new URL(
service.createUrl('project-id', 't.example.com', targetId),
)
expect(service.enabled).toBe(true)
expect(url.origin).toBe('https://dash.cloudflare.com')
expect(url.pathname).toBe(
`/domainconnect/v2/domainTemplates/providers/${template.providerId}/services/${template.serviceId}/apply`,
)
const query = url.search.slice(1).split('&key=')[0]
const signature = Buffer.from(url.searchParams.get('sig'), 'base64')
expect(
verify('RSA-SHA256', Buffer.from(query), keys.publicKey, signature),
).toBe(true)
expect(
verify(
'RSA-SHA256',
Buffer.from(query.replace('host=t&', 'host=www&')),
keys.publicKey,
signature,
),
).toBe(false)
expect([...url.searchParams.keys()].slice(-2)).toEqual(['key', 'sig'])
expect(url.searchParams.get('proxyTargetId')).toBe(targetId)
expect(
template.records[0].pointsTo.replace('%proxyTargetId%', targetId),
).toBe(`${targetId}.proxy.swetrix.org`)
const redirect = new URL(url.searchParams.get('redirect_uri'))
expect(redirect.origin).toBe('https://swetrix.com')
expect(redirect.pathname).toBe('/projects/settings/project-id')
expect(redirect.searchParams.get('tab')).toBe('proxy')
expect(redirect.searchParams.get('cloudflare')).toBe('t.example.com')
})

it('keeps nested hosts intact under multi-label public suffixes', () => {
const url = new URL(
new ProxyDomainConnectService().createUrl(
'pid',
't.shop.example.co.uk',
targetId,
),
)
expect(url.searchParams.get('domain')).toBe('example.co.uk')
expect(url.searchParams.get('host')).toBe('t.shop')
})

it('accepts escaped newlines in the private key', () => {
process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY = pem.replace(
/\n/g,
'\\n',
)
expect(new ProxyDomainConnectService().enabled).toBe(true)
})

it.each([
['MANAGED_PROXY_DOMAIN_CONNECT_ENABLED', 'false'],
['MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY', ''],
['MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY', 'invalid'],
['MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID', ''],
['MANAGED_PROXY_DOMAIN_CONNECT_KEY_ID', '../invalid'],
['MANAGED_PROXY_BASE_DOMAIN', 'other.example.com'],
['CLIENT_URL', 'http://swetrix.com'],
])('disables setup for invalid configuration: %s', (name, value) => {
process.env[name] = value
const service = new ProxyDomainConnectService()
expect(service.enabled).toBe(false)
expect(() => service.createUrl('pid', 't.example.com', targetId)).toThrow(
'Cloudflare DNS setup is unavailable',
)
})

it('rejects non-RSA keys', () => {
process.env.MANAGED_PROXY_DOMAIN_CONNECT_PRIVATE_KEY = generateKeyPairSync(
'ec',
{ namedCurve: 'P-256' },
)
.privateKey.export({ type: 'pkcs8', format: 'pem' })
.toString()
expect(new ProxyDomainConnectService().enabled).toBe(false)
})

it('rejects apex domains and target substitution', () => {
const service = new ProxyDomainConnectService()
expect(() => service.createUrl('pid', 'example.co.uk', targetId)).toThrow(
'Invalid managed proxy domain',
)
expect(() =>
service.createUrl('pid', 't.example.com', 'evil.example.com'),
).toThrow('Invalid managed proxy domain')
})
41 changes: 40 additions & 1 deletion backend/apps/cloud/src/project/proxy-domain.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { ProjectService } from './project.service'
import { ProxyDomainService, PROXY_BASE_DOMAIN } from './proxy-domain.service'
import { ProxyDomainCreateDTO } from './dto'
import { ProxyDomainEdgeGuard } from './proxy-domain-edge.guard'
import { ProxyDomainConnectService } from './proxy-domain-connect.service'

@ApiTags('Project - Managed Reverse Proxy')
@Controller(['project', 'v1/project'])
export class ProxyDomainController {
constructor(
private readonly proxyDomainService: ProxyDomainService,
private readonly projectService: ProjectService,
private readonly domainConnectService: ProxyDomainConnectService,
) {}

@ApiBearerAuth()
Expand All @@ -46,7 +48,44 @@ export class ProxyDomainController {
this.projectService.allowedToManage(project, uid)

const domains = await this.proxyDomainService.listForProject(pid)
return { domains, proxyBaseDomain: PROXY_BASE_DOMAIN }
return {
domains,
proxyBaseDomain: PROXY_BASE_DOMAIN,
cloudflareSetupAvailable: this.domainConnectService.enabled,
}
}

@ApiBearerAuth()
@Post(':pid/proxy-domains/:id/cloudflare')
@Auth()
@HttpCode(200)
@Header('Cache-Control', 'no-store')
@ApiOperation({
summary: 'Authorize managed proxy DNS setup with Cloudflare',
})
async configureCloudflare(
@Param('pid') pid: string,
@Param('id') id: string,
@CurrentUserId() uid: string,
) {
if (!isValidPID(pid)) {
throw new BadRequestException('The provided project ID is incorrect')
}

const project = await this.projectService.getFullProject(pid)
if (!project) {
throw new NotFoundException('Project not found')
}
this.projectService.allowedToManage(project, uid)

const domain = await this.proxyDomainService.findById(pid, id)
return {
url: this.domainConnectService.createUrl(
pid,
domain.hostname,
domain.proxyTargetId,
),
}
}

@ApiBearerAuth()
Expand Down
Loading
Loading