Skip to content

Commit 15f2390

Browse files
agunnerson-elasticabbierwolf
authored andcommitted
Add support for specifying CAA records (exentriquesolutions#53)
This allows specifying which CA authorities are allowed to issue certificates for subdomains or disallow issuing certificates entirely. This is disabled by default and is intended for situations where having valid TLS certificates is not necessary and it's not desirable to have a malicious IP (eg. phishing site) potentially appear more legitimate due to the valid certificate. This commit includes a couple minor fixes: - _get_env_splitted() now calls split(..., 1) instead of split(..., 2), which actually produces up to 3 parts. - The individual _write("END") calls in various qtype handlers were replaced by a single write_end() call at the end of run()'s loop.
1 parent 3cc0f14 commit 15f2390

File tree

5 files changed

+134
-19
lines changed

5 files changed

+134
-19
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ An IP address must be in one of the whitelisted ranges for a response to be retu
4545

4646
`NIPIO_BLACKLIST`: A space-separated list of description=ip blacklisted pairs. Example: `some_description=10.0.0.1 other_description=10.0.0.2`.
4747

48+
`NIPIO_CAA`: A space-separated list of description=value pairs for CAA `issue` records returned for whitelisted IPs. Example: `letsencrypt=letsencrypt.org`.
49+
4850
This is useful if you're creating your own [Dockerfile](Dockerfile).
4951

5052
## Troubleshooting

nipio/backend.conf

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,12 @@ private_net_192_168 = 192.168.0.0/16
5151

5252
# blacklisted ips (optional)
5353
[blacklist]
54-
#some_description = 10.0.0.1
54+
some_description = 10.0.0.1
55+
56+
57+
# CAA `issue` records to include for whitelisted IPs.
58+
[caa]
59+
# To limit to Lets Encrypt only:
60+
# letsencrypt=letsencrypt.org
61+
# To block all CAs from issuing certificates:
62+
# deny_all=;

nipio/backend.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _get_env_splitted(
5555
values = environment_value.split(linesep)
5656
result: List[Tuple[str, str]] = []
5757
for value in values:
58-
parts = value.split(pairsep, 2)
58+
parts = value.split(pairsep, 1)
5959
result.append((parts[0], parts[1]))
6060
return result
6161
else:
@@ -111,6 +111,7 @@ class DynamicBackend:
111111
name_servers
112112
whitelisted_ranges
113113
blacklisted_ips
114+
caa
114115
bits
115116
auth
116117
acme_challenge
@@ -130,6 +131,8 @@ class DynamicBackend:
130131
NIPIO_WHITELIST -- A space-separated list of description=range pairs to whitelist.
131132
The range should be in CIDR format.
132133
NIPIO_BLACKLIST -- A space-separated list of description=ip blacklisted pairs.
134+
NIPIO_CAA -- A space-separated list of description=value pairs for CAA `issue`
135+
records returned for whitelisted IPs
133136
NIPIO_AUTH -- Indicates whether this response is authoritative, this is for DNSSEC.
134137
NIPIO_BITS -- Scopebits indicates how many bits from the subnet provided in
135138
the question.
@@ -151,6 +154,7 @@ def __init__(self) -> None:
151154
self.name_servers: Dict[str, str] = {}
152155
self.whitelisted_ranges: List[IPv4Network] = []
153156
self.blacklisted_ips: List[str] = []
157+
self.caa: List[str] = []
154158
self.bits = "0"
155159
self.auth = "1"
156160
self.acme_challenge = ''
@@ -204,7 +208,13 @@ def configure(self, config_filename: str = _get_default_config_file()) -> None:
204208
):
205209
self.blacklisted_ips.append(entry[1])
206210

207-
self.acme_challenge = config.get('acme', 'challenge')
211+
if "NIPIO_CAA" in os.environ or config.has_section("caa"):
212+
for entry in _get_env_splitted(
213+
"NIPIO_CAA",
214+
config.items("caa") if config.has_section("caa") else [],
215+
):
216+
self.caa.append(entry[1])
217+
_log(f"CAA: {self.caa}")
208218

209219
_log(f"Name servers: {self.name_servers}")
210220
_log(f"ID: {self.id}")
@@ -214,6 +224,8 @@ def configure(self, config_filename: str = _get_default_config_file()) -> None:
214224
_log(f"Domain: {self.domain}")
215225
_log(f"Whitelisted IP ranges: {[str(r) for r in self.whitelisted_ranges]}")
216226
_log(f"Blacklisted IPs: {self.blacklisted_ips}")
227+
228+
self.acme_challenge = config.get('acme', 'challenge')
217229
_log(f"ACME challenge: {self.acme_challenge}")
218230

219231
def run(self) -> None:
@@ -251,26 +263,31 @@ def run(self) -> None:
251263
qname = cmd[1].lower()
252264
qtype = cmd[3]
253265

254-
if (qtype == "A" or qtype == "ANY") and qname.endswith(self.domain):
266+
if qtype in ("ANY", "A", "CAA") and qname.endswith(self.domain):
255267
if qname == self.domain:
256-
self.handle_self(self.domain)
268+
self.handle_self(qtype, self.domain)
257269
elif qname in self.name_servers:
258-
self.handle_nameservers(qname)
270+
self.handle_nameservers(qtype, qname)
259271
elif qname == '_acme-challenge.' + self.domain and self.acme_challenge:
260272
self.handle_acme(qname)
261273
else:
262-
self.handle_subdomains(qname)
274+
self.handle_subdomains(qtype, qname)
263275
elif qtype == "SOA" and qname.endswith(self.domain):
264276
self.handle_soa(qname)
265277
elif qtype == 'TXT' and qname == '_acme-challenge.' + self.domain and self.acme_challenge:
266278
self.handle_acme(qname)
267279
else:
268280
self.handle_unknown(qtype, qname)
269281

282+
self.write_end()
283+
270284
def write_end(self) -> None:
271285
_write("END")
272286

273-
def handle_self(self, name: str) -> None:
287+
def handle_self(self, qtype: str, name: str) -> None:
288+
if qtype not in ("ANY", "A"):
289+
return
290+
274291
_write(
275292
"DATA",
276293
self.bits,
@@ -283,9 +300,8 @@ def handle_self(self, name: str) -> None:
283300
self.ip_address,
284301
)
285302
self.write_name_servers(name)
286-
_write("END")
287303

288-
def handle_subdomains(self, qname: str) -> None:
304+
def handle_subdomains(self, qtype: str, qname: str) -> None:
289305
subdomain = qname[0 : qname.find(self.domain) - 1]
290306

291307
subparts = self._split_subdomain(subdomain)
@@ -314,7 +330,10 @@ def handle_subdomains(self, qname: str) -> None:
314330
self.handle_blacklisted(ip_address)
315331
return
316332

317-
self.handle_resolved(ip_address, qname)
333+
if qtype in ("ANY", "A"):
334+
self.handle_resolved(ip_address, qname)
335+
if qtype in ("ANY", "CAA"):
336+
self.handle_caa(qname)
318337

319338
def handle_resolved(self, address: IPv4Address, qname: str) -> None:
320339
_write(
@@ -329,12 +348,29 @@ def handle_resolved(self, address: IPv4Address, qname: str) -> None:
329348
str(address),
330349
)
331350
self.write_name_servers(qname)
332-
_write("END")
333351

334-
def handle_nameservers(self, qname: str) -> None:
352+
def handle_caa(self, qname: str) -> None:
353+
for value in self.caa:
354+
_write(
355+
"DATA",
356+
self.bits,
357+
self.auth,
358+
qname,
359+
"IN",
360+
"CAA",
361+
self.ttl,
362+
self.id,
363+
"0",
364+
"issue",
365+
'"%s"' % value,
366+
)
367+
368+
def handle_nameservers(self, qtype: str, qname: str) -> None:
369+
if qtype not in ("ANY", "A"):
370+
return
371+
335372
ip = self.name_servers[qname]
336373
_write("DATA", self.bits, self.auth, qname, "IN", "A", self.ttl, self.id, ip)
337-
_write("END")
338374

339375

340376
def handle_acme(self, qname):
@@ -369,23 +405,18 @@ def handle_soa(self, qname: str) -> None:
369405
self.id,
370406
self.soa,
371407
)
372-
_write("END")
373408

374409
def handle_unknown(self, qtype: str, qname: str) -> None:
375410
_write("LOG", f"Unknown type: {qtype}, domain: {qname}")
376-
_write("END")
377411

378412
def handle_not_whitelisted(self, ip_address: IPv4Address) -> None:
379413
_write("LOG", f"Not Whitelisted: {ip_address}")
380-
_write("END")
381414

382415
def handle_blacklisted(self, ip_address: IPv4Address) -> None:
383416
_write("LOG", f"Blacklisted: {ip_address}")
384-
_write("END")
385417

386418
def handle_invalid_ip(self, ip_address: str) -> None:
387419
_write("LOG", f"Invalid IP address: {ip_address}")
388-
_write("END")
389420

390421
def _split_subdomain(self, subdomain: str) -> List[str]:
391422
match = re.search("(?:^|.*[.-])([0-9A-Fa-f]{8})$", subdomain)

nipio_tests/backend_test.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,8 @@ private_net = 192.168.0.0/16
4747
# blacklist
4848
[blacklist]
4949
some_description = 10.0.0.100
50+
51+
52+
# CAA
53+
[caa]
54+
letsencrypt = letsencrypt.org;validationmethods=http-01

nipio_tests/backend_test.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,19 @@ def test_backend_with_empty_whitelist_responds_to_ANY_request_for_valid_ip(
128128
"22",
129129
"ns2.nip.io.test",
130130
],
131+
[
132+
"DATA",
133+
"0",
134+
"1",
135+
"subdomain.10.0.10.1.nip.io.test",
136+
"IN",
137+
"CAA",
138+
"200",
139+
"22",
140+
"0",
141+
"issue",
142+
'"letsencrypt.org;validationmethods=http-01"',
143+
],
131144
)
132145

133146
def test_backend_with_empty_whitelist_responds_to_A_request_for_valid_ip(
@@ -216,6 +229,19 @@ def test_backend_responds_to_ANY_request_with_valid_ip(self) -> None:
216229
"22",
217230
"ns2.nip.io.test",
218231
],
232+
[
233+
"DATA",
234+
"0",
235+
"1",
236+
"subdomain.127.0.0.1.nip.io.test",
237+
"IN",
238+
"CAA",
239+
"200",
240+
"22",
241+
"0",
242+
"issue",
243+
'"letsencrypt.org;validationmethods=http-01"',
244+
],
219245
)
220246

221247
def test_backend_responds_to_A_request_with_valid_ip(self) -> None:
@@ -261,6 +287,29 @@ def test_backend_responds_to_A_request_with_valid_ip(self) -> None:
261287
],
262288
)
263289

290+
def test_backend_responds_to_CAA_request(self) -> None:
291+
self._send_commands(
292+
["Q", "subdomain.127.0.0.1.nip.io.test", "IN", "CAA", "1", "127.0.0.1"]
293+
)
294+
295+
self._run_backend()
296+
297+
self._assert_expected_responses(
298+
[
299+
"DATA",
300+
"0",
301+
"1",
302+
"subdomain.127.0.0.1.nip.io.test",
303+
"IN",
304+
"CAA",
305+
"200",
306+
"22",
307+
"0",
308+
"issue",
309+
'"letsencrypt.org;validationmethods=http-01"',
310+
],
311+
)
312+
264313
def test_backend_responds_to_ANY_request_with_valid_ip_separated_by_dashes(
265314
self,
266315
) -> None:
@@ -304,6 +353,19 @@ def test_backend_responds_to_ANY_request_with_valid_ip_separated_by_dashes(
304353
"22",
305354
"ns2.nip.io.test",
306355
],
356+
[
357+
"DATA",
358+
"0",
359+
"1",
360+
"subdomain-127-0-0-1.nip.io.test",
361+
"IN",
362+
"CAA",
363+
"200",
364+
"22",
365+
"0",
366+
"issue",
367+
'"letsencrypt.org;validationmethods=http-01"',
368+
],
307369
)
308370

309371
def test_backend_responds_to_A_request_with_valid_ip_separated_by_dashes(
@@ -812,6 +874,9 @@ def test_configure_with_env_lists_config(self) -> None:
812874
os.environ[
813875
"NIPIO_BLACKLIST"
814876
] = "black_listed=10.0.0.111 black_listed2=10.0.0.112"
877+
os.environ[
878+
"NIPIO_CAA"
879+
] = "letsencrypt=letsencrypt.org;validationmethods=http-01"
815880
backend = self._configure_backend(filename="backend_test_no_lists.conf")
816881

817882
assert_that(backend.whitelisted_ranges).is_equal_to(
@@ -820,6 +885,9 @@ def test_configure_with_env_lists_config(self) -> None:
820885
]
821886
)
822887
assert_that(backend.blacklisted_ips).is_equal_to(["10.0.0.111", "10.0.0.112"])
888+
assert_that(backend.caa).is_equal_to(
889+
["letsencrypt.org;validationmethods=http-01"]
890+
)
823891

824892
def test_configure_with_config_missing_lists(self) -> None:
825893
backend = self._configure_backend(filename="backend_test_no_lists.conf")
@@ -897,6 +965,7 @@ def _create_backend() -> DynamicBackend:
897965
ipaddress.IPv4Network("222.173.190.239/32"),
898966
]
899967
backend.blacklisted_ips = ["127.0.0.2"]
968+
backend.caa = ["letsencrypt.org;validationmethods=http-01"]
900969
return backend
901970

902971
@staticmethod

0 commit comments

Comments
 (0)