Skip to content

Commit 67d7bcf

Browse files
zsteinkampivanitskiy
authored andcommitted
feat: Fixes #15 - Validate hostnames and adds capability for unit tests in dev
1 parent 435efe5 commit 67d7bcf

File tree

10 files changed

+153
-8
lines changed

10 files changed

+153
-8
lines changed

Dockerfile

+23
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# syntax=docker/dockerfile:1
12
ARG NGINX_VERSION=1.24.0
23

34
FROM node:18 AS builder
@@ -14,3 +15,25 @@ COPY --from=builder /app/dist/acme.js /usr/lib/nginx/njs_modules/acme.js
1415
COPY ./examples/nginx.conf /etc/nginx/nginx.conf
1516
RUN mkdir /etc/nginx/njs-acme
1617
RUN chown nginx: /etc/nginx/njs-acme
18+
19+
# install the latest njs > 0.8.0 (not yet bundled with nginx-1.25.1)
20+
RUN --mount=type=cache,target=/var/cache/apt <<EOF
21+
set -eux
22+
export DEBIAN_FRONTEND=noninteractive
23+
apt -qq update
24+
apt install -qq --yes --no-install-recommends --no-install-suggests \
25+
curl gnupg2 ca-certificates lsb-release debian-archive-keyring
26+
update-ca-certificates
27+
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
28+
| tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
29+
gpg --dry-run --quiet --no-keyring --import --import-options import-show \
30+
/usr/share/keyrings/nginx-archive-keyring.gpg
31+
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
32+
http://nginx.org/packages/mainline/debian `lsb_release -cs` nginx" \
33+
| tee /etc/apt/sources.list.d/nginx.list
34+
apt update -qq
35+
apt install -qq --yes --no-install-recommends --no-install-suggests \
36+
nginx-module-njs
37+
apt remove --purge --auto-remove --yes
38+
rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list
39+
EOF

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,8 @@ Look at `clientAutoMode` in [`src/index.ts`](./src/index.ts) to see how you can
280280
| Path | Description |
281281
| ---- | ------------|
282282
| [src](src) | Contains your source code that will be compiled to the `dist/` directory. |
283-
| [integration-tests](integration-tests) | Contains your source code of tests. |
283+
| [integration-tests](integration-tests) | Integration tests. |
284+
| [unit-tests](unit-tests) | Unit tests for code in `src/`. |
284285

285286
## Contributing
286287

examples/nginx.conf

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ http {
2929

3030
server {
3131
listen 0.0.0.0:8000; # testing with 8000 should be 80 in prod, pebble usees httpPort in dev/pebble/config.json
32-
listen 443 ssl http2;
32+
listen 443 ssl;
3333
server_name proxy.nginx.com;
3434

3535
# The full set of configuration variables. These can also be defined in

integration-tests/acme-auto.test.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { strict as assert } from 'assert'
2-
import { test } from 'mocha'
32
import './hooks'
43

54
interface CertificateData {

src/tsconfig.json

-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"resolveJsonModule": true,
3030
"rootDir": "../",
3131
"typeRoots": [],
32-
"types": ["njs-types"]
3332
},
3433
"include": [".", "../package.json"],
3534
"exclude": ["../node_modules"],

src/utils.ts

+25-3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import * as pkijs from 'pkijs'
33
import * as asn1js from 'asn1js'
44
import fs from 'fs'
55
import querystring from 'querystring'
6-
import { ClientExternalAccountBindingOptions } from './client.js'
7-
import { Logger } from './logger.js'
6+
import { ClientExternalAccountBindingOptions } from './client'
7+
import { Logger } from './logger'
88

99
const log = new Logger('utils')
1010

@@ -833,7 +833,17 @@ export function acmeAltNames(r: NginxHTTPRequest): string[] {
833833
export function acmeServerNames(r: NginxHTTPRequest): string[] {
834834
const nameStr = getVariable(r, 'njs_acme_server_names') // no default == mandatory
835835
// split string value on comma and/or whitespace and lowercase each element
836-
return nameStr.split(/[,\s]+/).map((n) => n.toLowerCase())
836+
const names = nameStr.split(/[,\s]+/)
837+
const invalidNames = names.filter((name) => !isValidHostname(name))
838+
839+
if (invalidNames.length > 0) {
840+
const errMsg =
841+
'Invalid hostname(s) in `njs_acme_server_names` detected: ' +
842+
invalidNames.join(', ')
843+
log.error(errMsg)
844+
throw new Error(errMsg)
845+
}
846+
return names.map((n) => n.toLowerCase())
837847
}
838848

839849
/**
@@ -926,3 +936,15 @@ export function joinPaths(...args: string[]): string {
926936
// join args with a slash remove duplicate slashes
927937
return args.join('/').replace(/\/+/g, '/')
928938
}
939+
940+
export function isValidHostname(hostname: string): boolean {
941+
return (
942+
!!hostname &&
943+
hostname.length < 256 &&
944+
!!hostname.match(
945+
// hostnames are dot-separated groups of letters, numbers, hyphens (but
946+
// not beginning or ending with hyphens), and may end with a period
947+
/^[a-z\d]([-a-z\d]{0,61}[a-z\d])?(\.[a-z\d]([-a-z\d]{0,61}[a-z\d])?)*\.?$/i
948+
)
949+
)
950+
}

unit-tests/.mocharc.js

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module.exports = {
99
'babel-register-ts',
1010
'source-map-support/register',
1111
],
12+
file: 'unit-tests/setupGlobals.ts',
1213
spec: [
1314
'unit-tests/**/*.test.ts',
1415
],

unit-tests/setupGlobals.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { webcrypto } from 'crypto'
2+
3+
/**
4+
* Fake implementation of the logging functions of NGXObject
5+
*/
6+
class FakeNGX {
7+
readonly logs: { level: number; message: NjsStringOrBuffer }[]
8+
readonly INFO: number
9+
readonly WARN: number
10+
readonly ERR: number
11+
constructor() {
12+
this.logs = []
13+
this.INFO = 1
14+
this.WARN = 2
15+
this.ERR = 3
16+
}
17+
log(level: number, message: NjsStringOrBuffer) {
18+
this.logs.push({ level, message })
19+
}
20+
}
21+
22+
const globalAny: Record<string, unknown> = global
23+
globalAny.ngx = new FakeNGX()
24+
globalAny.crypto = webcrypto

unit-tests/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "../tsconfig.json",
33
"compilerOptions": {
4-
"types": ["njs-types", "node"]
4+
"types": ["node"]
55
},
66
"files": [
77
"../node_modules/njs-types/ngx_http_js_module.d.ts",

unit-tests/utils.test.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from 'assert'
2+
import { describe, it } from 'mocha'
3+
import { acmeServerNames, isValidHostname } from '../src/utils'
4+
5+
describe('utils', () => {
6+
describe('isValidHostname', () => {
7+
it('returns true for valid names', () => {
8+
assert(isValidHostname('nginx.com'))
9+
assert(isValidHostname('5guys.nginx.com'))
10+
assert(isValidHostname('5guys.nginx.com.'))
11+
assert(isValidHostname('5-guys.'))
12+
assert(isValidHostname('5'))
13+
assert(isValidHostname('a-z.123'))
14+
assert(isValidHostname('a.'.repeat(100)))
15+
assert(isValidHostname('a'.repeat(61) + '.com'))
16+
})
17+
it('returns false for invalid names', () => {
18+
assert(!isValidHostname('.com'))
19+
assert(!isValidHostname('.foobarbaz'))
20+
assert(!isValidHostname('*.nginx.com'))
21+
assert(!isValidHostname('-5guys.'))
22+
assert(!isValidHostname('5guys-'))
23+
assert(!isValidHostname('domäin.'))
24+
assert(!isValidHostname(' '))
25+
assert(!isValidHostname(''))
26+
assert(!isValidHostname('a'.repeat(65) + '.com'))
27+
assert(!isValidHostname('*'))
28+
assert(
29+
!isValidHostname(
30+
// too long - 256 chars
31+
'1234567890abcdef'.repeat(16)
32+
)
33+
)
34+
})
35+
})
36+
describe('acmeServerNames', () => {
37+
it('returns an array given valid input', () => {
38+
const r = {
39+
variables: {
40+
njs_acme_server_names: null,
41+
},
42+
} as unknown as NginxHTTPRequest
43+
const testCases = {
44+
'nginx.com': 1,
45+
'foo.bar.baz': 1,
46+
'foo.bar.baz foo.baz': 2,
47+
'foo. bar. baz.': 3,
48+
'foo.,bar.': 2,
49+
'foo.\tbar.': 2,
50+
'foo. bar.': 2,
51+
}
52+
53+
for (const [names, expected] of Object.entries(testCases)) {
54+
r.variables.njs_acme_server_names = names
55+
const result = acmeServerNames(r)
56+
assert(result.length === expected)
57+
}
58+
})
59+
it('throws given invalid input', () => {
60+
const r = {
61+
variables: {
62+
njs_acme_server_names: null,
63+
},
64+
} as unknown as NginxHTTPRequest
65+
for (const name of [
66+
'nginx-.com',
67+
'*.bar.baz',
68+
'foo.bar.baz *.baz',
69+
'-foo. bar. baz.',
70+
]) {
71+
r.variables.njs_acme_server_names = name
72+
assert.throws(() => acmeServerNames(r))
73+
}
74+
})
75+
})
76+
})

0 commit comments

Comments
 (0)