Skip to content

Commit 979ef7f

Browse files
Add support for specifying CAA records
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 512b785 commit 979ef7f

File tree

5 files changed

+132
-17
lines changed

5 files changed

+132
-17
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,11 @@ private_net_192_168 = 192.168.0.0/16
5050
# blacklisted ips (optional)
5151
[blacklist]
5252
some_description = 10.0.0.1
53+
54+
55+
# CAA `issue` records to include for whitelisted IPs.
56+
[caa]
57+
# To limit to Lets Encrypt only:
58+
# letsencrypt=letsencrypt.org
59+
# To block all CAs from issuing certificates:
60+
# deny_all=;

nipio/backend.py

Lines changed: 48 additions & 17 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
@@ -129,6 +130,8 @@ class DynamicBackend:
129130
NIPIO_WHITELIST -- A space-separated list of description=range pairs to whitelist.
130131
The range should be in CIDR format.
131132
NIPIO_BLACKLIST -- A space-separated list of description=ip blacklisted pairs.
133+
NIPIO_CAA -- A space-separated list of description=value pairs for CAA `issue`
134+
records returned for whitelisted IPs
132135
NIPIO_AUTH -- Indicates whether this response is authoritative, this is for DNSSEC.
133136
NIPIO_BITS -- Scopebits indicates how many bits from the subnet provided in
134137
the question.
@@ -150,6 +153,7 @@ def __init__(self) -> None:
150153
self.name_servers: Dict[str, str] = {}
151154
self.whitelisted_ranges: List[IPv4Network] = []
152155
self.blacklisted_ips: List[str] = []
156+
self.caa: List[str] = []
153157
self.bits = "0"
154158
self.auth = "1"
155159

@@ -202,6 +206,13 @@ def configure(self, config_filename: str = _get_default_config_file()) -> None:
202206
):
203207
self.blacklisted_ips.append(entry[1])
204208

209+
if "NIPIO_CAA" in os.environ or config.has_section("caa"):
210+
for entry in _get_env_splitted(
211+
"NIPIO_CAA",
212+
config.items("caa") if config.has_section("caa") else [],
213+
):
214+
self.caa.append(entry[1])
215+
205216
_log(f"Name servers: {self.name_servers}")
206217
_log(f"ID: {self.id}")
207218
_log(f"TTL: {self.ttl}")
@@ -210,6 +221,7 @@ def configure(self, config_filename: str = _get_default_config_file()) -> None:
210221
_log(f"Domain: {self.domain}")
211222
_log(f"Whitelisted IP ranges: {[str(r) for r in self.whitelisted_ranges]}")
212223
_log(f"Blacklisted IPs: {self.blacklisted_ips}")
224+
_log(f"CAA: {self.caa}")
213225

214226
def run(self) -> None:
215227
"""Run the pipe backend.
@@ -246,22 +258,27 @@ def run(self) -> None:
246258
qname = cmd[1].lower()
247259
qtype = cmd[3]
248260

249-
if (qtype == "A" or qtype == "ANY") and qname.endswith(self.domain):
261+
if qtype in ("ANY", "A", "CAA") and qname.endswith(self.domain):
250262
if qname == self.domain:
251-
self.handle_self(self.domain)
263+
self.handle_self(qtype, self.domain)
252264
elif qname in self.name_servers:
253-
self.handle_nameservers(qname)
265+
self.handle_nameservers(qtype, qname)
254266
else:
255-
self.handle_subdomains(qname)
267+
self.handle_subdomains(qtype, qname)
256268
elif qtype == "SOA" and qname.endswith(self.domain):
257269
self.handle_soa(qname)
258270
else:
259271
self.handle_unknown(qtype, qname)
260272

273+
self.write_end()
274+
261275
def write_end(self) -> None:
262276
_write("END")
263277

264-
def handle_self(self, name: str) -> None:
278+
def handle_self(self, qtype: str, name: str) -> None:
279+
if qtype not in ("ANY", "A"):
280+
return
281+
265282
_write(
266283
"DATA",
267284
self.bits,
@@ -274,9 +291,8 @@ def handle_self(self, name: str) -> None:
274291
self.ip_address,
275292
)
276293
self.write_name_servers(name)
277-
_write("END")
278294

279-
def handle_subdomains(self, qname: str) -> None:
295+
def handle_subdomains(self, qtype: str, qname: str) -> None:
280296
subdomain = qname[0 : qname.find(self.domain) - 1]
281297

282298
subparts = self._split_subdomain(subdomain)
@@ -305,7 +321,10 @@ def handle_subdomains(self, qname: str) -> None:
305321
self.handle_blacklisted(ip_address)
306322
return
307323

308-
self.handle_resolved(ip_address, qname)
324+
if qtype in ("ANY", "A"):
325+
self.handle_resolved(ip_address, qname)
326+
if qtype in ("ANY", "CAA"):
327+
self.handle_caa(qname)
309328

310329
def handle_resolved(self, address: IPv4Address, qname: str) -> None:
311330
_write(
@@ -320,12 +339,29 @@ def handle_resolved(self, address: IPv4Address, qname: str) -> None:
320339
str(address),
321340
)
322341
self.write_name_servers(qname)
323-
_write("END")
324342

325-
def handle_nameservers(self, qname: str) -> None:
343+
def handle_caa(self, qname: str) -> None:
344+
for value in self.caa:
345+
_write(
346+
"DATA",
347+
self.bits,
348+
self.auth,
349+
qname,
350+
"IN",
351+
"CAA",
352+
self.ttl,
353+
self.id,
354+
"0",
355+
"issue",
356+
'"%s"' % value,
357+
)
358+
359+
def handle_nameservers(self, qtype: str, qname: str) -> None:
360+
if qtype not in ("ANY", "A"):
361+
return
362+
326363
ip = self.name_servers[qname]
327364
_write("DATA", self.bits, self.auth, qname, "IN", "A", self.ttl, self.id, ip)
328-
_write("END")
329365

330366
def write_name_servers(self, qname: str) -> None:
331367
for name_server in self.name_servers:
@@ -353,23 +389,18 @@ def handle_soa(self, qname: str) -> None:
353389
self.id,
354390
self.soa,
355391
)
356-
_write("END")
357392

358393
def handle_unknown(self, qtype: str, qname: str) -> None:
359394
_write("LOG", f"Unknown type: {qtype}, domain: {qname}")
360-
_write("END")
361395

362396
def handle_not_whitelisted(self, ip_address: IPv4Address) -> None:
363397
_write("LOG", f"Not Whitelisted: {ip_address}")
364-
_write("END")
365398

366399
def handle_blacklisted(self, ip_address: IPv4Address) -> None:
367400
_write("LOG", f"Blacklisted: {ip_address}")
368-
_write("END")
369401

370402
def handle_invalid_ip(self, ip_address: str) -> None:
371403
_write("LOG", f"Invalid IP address: {ip_address}")
372-
_write("END")
373404

374405
def _split_subdomain(self, subdomain: str) -> List[str]:
375406
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)