ReviewOS

also looking at this

stacks/ts-cloud

fix: validate site domain before it reaches nginx server_name

#129
Merged glennmichael123 wants to merge fix/nginx-server-name-injection into main
5 files +143 -5
packages/ts-cloud/src/deploy/local-dashboard-server.tsmodified+10-1
Changes to packages/ts-cloud/src/deploy/local-dashboard-server.ts
@@ -40,7 +40,7 @@ import {
4040 setServerlessSecret,
4141 updateFunctionConfig,
4242} from './serverless-operations'
43import { addSiteToCloudConfig, removeSiteFromCloudConfig, renderAliasesValue, renderEnvValue, renderRedirectsValue, renderSslValue, renderStringValue, setSitePropertyInCloudConfig } from './site-config-editor'
43import { addSiteToCloudConfig, isValidHostname, removeSiteFromCloudConfig, renderAliasesValue, renderEnvValue, renderRedirectsValue, renderSslValue, renderStringValue, setSitePropertyInCloudConfig } from './site-config-editor'
4444import { addSshKeyToCloudConfig, describeSshKeys, removeSshKeyFromCloudConfig } from './ssh-config-editor'
4545import { createTerminalSession } from './terminal-session'
4646
@@ -860,6 +860,15 @@ export async function startLocalDashboardServer(options: LocalDashboardServerOpt
860860 if (body.port !== undefined && body.port !== null && body.port !== '' && (!Number.isInteger(Number(body.port)) || Number(body.port) < 1 || Number(body.port) > 65_535))
861861 return json({ ok: false, error: 'Port must be a number between 1 and 65535.' }, 422)
862862
863 // `domain` lands in the generated nginx `server_name`, so it must be a
864 // hostname and nothing else — an unvalidated value can close the
865 // server block and open an attacker-controlled one. `aliases` (same
866 // destination) has always been checked; this closes the gap for the
867 // primary domain. Validated before any write so a bad value can't
868 // leave the config half-edited.
869 if (typeof body.domain === 'string' && body.domain.trim() && !isValidHostname(body.domain.trim()))
870 return json({ ok: false, error: `Domain '${body.domain.trim()}' is not a valid hostname.` }, 422)
871
863872 let text = await readFile(configPath, 'utf8')
864873 const set = (key: string, valueText: string): void => {
865874 text = setSitePropertyInCloudConfig({ configText: text, siteName: name, key, valueText })
packages/ts-cloud/src/deploy/management-dashboard.tsmodified+8-2
Changes to packages/ts-cloud/src/deploy/management-dashboard.ts
@@ -97,10 +97,16 @@ export function resolveDashboardAuth(cwd: string, username: string, logger: Ensu
9797 mkdirSync(dirname(file), { recursive: true })
9898 writeFileSync(file, `${JSON.stringify({ username, password, generatedAt: new Date().toISOString() }, null, 2)}\n`)
9999 chmodSync(file, 0o600)
100 logger.info(`Management dashboard: generated a password and saved it to ${DASHBOARD_CREDENTIALS_FILE} (user: ${username}, pass: ${password}). Set TS_CLOUD_UI_PASSWORD to pin your own, or TS_CLOUD_UI_PUBLIC=1 to serve without auth.`)
100 // Deliberately NOT logging the password: deploy output lands in CI logs,
101 // terminal scrollback and the systemd journal, all of which outlive the
102 // deploy and are readable by more people than the 0600 file is.
103 logger.info(`Management dashboard: generated a password for '${username}' and saved it to ${DASHBOARD_CREDENTIALS_FILE} (read it there — it is not printed). Set TS_CLOUD_UI_PASSWORD to pin your own, or TS_CLOUD_UI_PUBLIC=1 to serve without auth.`)
101104 }
102105 catch (error: any) {
103 logger.warn(`Management dashboard: could not persist the generated password (${error?.message ?? error}). Using it for this deploy only — pass: ${password}`)
106 // Only place the password is still printed: persisting failed, so this log
107 // line is the operator's single copy. Say plainly that it is now in the log
108 // so they can rotate it once the underlying write problem is fixed.
109 logger.warn(`Management dashboard: could not persist the generated password (${error?.message ?? error}). Using it for this deploy only — pass: ${password}\nThis password is now in your deploy log. Set TS_CLOUD_UI_PASSWORD to a value of your own and redeploy once ${DASHBOARD_CREDENTIALS_FILE} is writable.`)
104110 }
105111 return { password, source: 'generated' }
106112}
packages/ts-cloud/src/deploy/site-config-editor.tsmodified+10-1
Changes to packages/ts-cloud/src/deploy/site-config-editor.ts
@@ -236,7 +236,16 @@ export function renderSiteSnippet(input: Omit<AddSiteConfigInput, 'configText'>)
236236}
237237
238238function escapeSingle(value: string): string {
239 return value.replace(/\\/g, '\\\\').replaceAll(String.fromCharCode(39), '\\\'')
239 // Newlines matter as much as quotes here: these values land in single-quoted
240 // TS string literals in the shared, box-wide cloud.config.ts. A raw newline
241 // terminates the literal and leaves the file syntactically broken for every
242 // tenant that loads it. It also keeps line-oriented sinks downstream (heredoc
243 // delimiters in the deploy script) safe from values that span lines.
244 return value
245 .replace(/\\/g, '\\\\')
246 .replaceAll(String.fromCharCode(39), '\\\'')
247 .replace(/\r/g, '\\r')
248 .replace(/\n/g, '\\n')
240249}
241250
242251function normalizeSiteName(name: string): string {
packages/ts-cloud/src/drivers/shared/nginx-vhost.tsmodified+18-1
Changes to packages/ts-cloud/src/drivers/shared/nginx-vhost.ts
@@ -269,7 +269,24 @@ function vhostBody(options: NginxVhostOptions): string[] {
269269 * otherwise a single :80 block (certbot upgrades it for Let's Encrypt).
270270 */
271271export function buildNginxVhost(options: NginxVhostOptions): string {
272 const serverNames = [options.domain, ...(options.aliases || [])].filter(Boolean).join(' ')
272 const hosts = [options.domain, ...(options.aliases || [])].filter(Boolean) as string[]
273 // Defense in depth: every token here is interpolated straight into a
274 // `server_name` directive, and nginx is whitespace-insensitive — so anything
275 // that isn't a bare hostname could close this block and open another. Callers
276 // validate too; refuse here as well so no future path can slip a directive in.
277 //
278 // Deliberately laxer than `isValidHostname` (which the dashboard API uses for
279 // user-supplied domains and which requires a dot): a single label is valid
280 // here because compute-deploy falls back to `site.domain || siteName`, so an
281 // internal site legitimately arrives as `main` or `docs`. What matters for
282 // safety is only that a token can't contain whitespace, `;`, `{` or `}`.
283 for (const host of hosts) {
284 if (!/^(?=.{1,253}$)(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(host.trim()))
285 throw new Error(`Refusing to build a vhost: '${host}' is not a valid hostname.`)
286 }
287 if (!hosts.length)
288 throw new Error('Refusing to build a vhost: no server_name (domain) was given.')
289 const serverNames = hosts.join(' ')
273290 const body = vhostBody(options)
274291
275292 if (options.ssl) {
packages/ts-cloud/test/drivers/nginx-vhost-injection.test.tsadded+97-0
Changes to packages/ts-cloud/test/drivers/nginx-vhost-injection.test.ts
@@ -0,0 +1,97 @@
1import { describe, expect, it } from 'bun:test'
2import { isValidHostname, renderStringValue } from '../../src/deploy/site-config-editor'
3import { buildNginxVhost } from '../../src/drivers/shared/nginx-vhost'
4
5/**
6 * Regression tests for nginx `server_name` injection.
7 *
8 * A site's `domain` is member-editable and is interpolated straight into the
9 * generated `server_name` directive. nginx is whitespace-insensitive, so an
10 * unvalidated value can close the server block and open an attacker-controlled
11 * one — e.g. `location / { root /; autoindex on; }`, which exposes the whole
12 * filesystem (other tenants' .env files, the dashboard user store, SSH keys)
13 * over HTTP on a shared box.
14 */
15
16// Closes the generated block, opens a filesystem-exposing one, then reopens a
17// server block so the result still parses.
18const INJECTION = 'x.com; } location / { root /; autoindex on; } server { server_name y.com'
19
20describe('server_name injection', () => {
21 it('rejects a domain carrying nginx directives', () => {
22 expect(isValidHostname(INJECTION)).toBe(false)
23 })
24
25 it('refuses to build a vhost from an injected domain', () => {
26 expect(() => buildNginxVhost({
27 siteName: 'app',
28 domain: INJECTION,
29 appDir: '/var/www/app/current',
30 })).toThrow(/not a valid hostname/)
31 })
32
33 it('refuses to build a vhost from an injected alias', () => {
34 expect(() => buildNginxVhost({
35 siteName: 'app',
36 domain: 'example.com',
37 aliases: [INJECTION],
38 appDir: '/var/www/app/current',
39 })).toThrow(/not a valid hostname/)
40 })
41
42 it('rejects whitespace, newlines and directive punctuation in a hostname', () => {
43 for (const bad of ['a.com b.com', 'a.com\nserver_name evil.com', 'a.com;', 'a.com{', 'a.com}']) {
44 expect(() => buildNginxVhost({
45 siteName: 'app',
46 domain: bad,
47 appDir: '/var/www/app/current',
48 })).toThrow(/not a valid hostname/)
49 }
50 })
51
52 it('rejects an empty server_name rather than emitting `server_name ;`', () => {
53 expect(() => buildNginxVhost({
54 siteName: 'app',
55 domain: '',
56 appDir: '/var/www/app/current',
57 })).toThrow(/no server_name/)
58 })
59
60 // compute-deploy falls back to `domain: site.domain || siteName`, so an
61 // internal site with no configured domain arrives here as a single label.
62 // The generator must keep accepting those or every such deploy breaks.
63 it('accepts a single-label host (the siteName fallback)', () => {
64 for (const host of ['main', 'docs', 'localhost']) {
65 const vhost = buildNginxVhost({
66 siteName: host,
67 domain: host,
68 appDir: '/var/www/app/current',
69 })
70 expect(vhost).toContain(`server_name ${host};`)
71 }
72 })
73
74 it('still builds a normal vhost, including wildcard aliases', () => {
75 const vhost = buildNginxVhost({
76 siteName: 'app',
77 domain: 'app.example.com',
78 aliases: ['www.example.com', '*.cdn.example.com'],
79 appDir: '/var/www/app/current',
80 })
81 expect(vhost).toContain('server_name app.example.com www.example.com *.cdn.example.com;')
82 })
83})
84
85describe('cloud.config.ts string escaping', () => {
86 it('escapes newlines so a value cannot terminate the string literal', () => {
87 const rendered = renderStringValue('a\nb')
88 expect(rendered).not.toContain('\n')
89 expect(rendered).toBe('\'a\\nb\'')
90 })
91
92 it('escapes carriage returns, quotes and backslashes', () => {
93 expect(renderStringValue('a\rb')).not.toContain('\r')
94 expect(renderStringValue('it\'s')).toBe('\'it\\\'s\'')
95 expect(renderStringValue('a\\b')).toBe('\'a\\\\b\'')
96 })
97})