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/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})