Skip to content

Commit 61a02fc

Browse files
diLLecMichael Kluge
andauthored
[Bug] Handle half validated requests with multiple domains (#185)
* Add the info which test fails * Fixing the half-validated bug On certificates with multiple subject names (subject_alt_name), there is the possibility that a validation goes halfway through. In such a case the filter_challenges filter plugin failed because it only searched in the authorizations field  when challenge_data field was empty. In a half-way validation the challenge_data will carry the tokens of the pending validations and therefore the find_challenges() fails on a expected_not_found validation. * Adding test for a half-way through validation * Fix linting --------- Co-authored-by: Michael Kluge <michael.kluge@telekom.de>
1 parent eec76c8 commit 61a02fc

File tree

2 files changed

+155
-46
lines changed

2 files changed

+155
-46
lines changed

plugins/filter/find_challenges.py

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,104 @@
22

33

44
def find_challenges(challenge_response: dict, challenge_type: str, expected_domains: list) -> dict:
5-
challenges = build_challenges(challenge_response, challenge_type)
6-
7-
if set(challenges.keys()) == set(expected_domains):
5+
"""
6+
1. check if all the expected domains are present in the challenge response
7+
2. construct and return the following dictionary out of the API response
8+
challenge_data and authorizations fields
9+
{'<domain>': {[http-01/dns-01]: 'resource': <http path/dns record'> resource_value': <token>}}
10+
"""
11+
challenges = build_challenges(challenge_response, challenge_type, expected_domains)
12+
domains_in_response = find_domains_in_response(challenge_response, challenge_type)
13+
errors = []
14+
15+
if domains_in_response == set(expected_domains):
816
return challenges
917
else:
10-
errors = []
11-
expected_not_found = set(expected_domains).difference(set(challenges.keys()))
18+
expected_not_found = set(expected_domains).difference(domains_in_response)
1219
if expected_not_found:
1320
errors.append(f"Expected {challenge_type} challenges for the following domains not found: "
1421
f"{','.join(expected_not_found)}")
1522

16-
unexpected_from_api = set(challenges.keys()).difference(set(expected_domains))
23+
unexpected_from_api = domains_in_response.difference(set(expected_domains))
1724
if unexpected_from_api:
1825
errors.append(f"The API responded with {challenge_type} challenges for the following domains "
1926
f"we did not expect: {','.join(unexpected_from_api)}")
27+
if errors:
28+
raise AnsibleError(" // ".join(errors))
29+
30+
return dict()
2031

21-
if errors:
22-
raise AnsibleError(" // ".join(errors))
2332

33+
def find_domains_in_response(challenge_response: dict, challenge_type: str) -> set:
34+
"""
35+
find the domains in the response for which the API responded with a challenge of the specified challenge_type
36+
"""
37+
domains = set()
38+
for domain, domain_data in challenge_response['challenge_data'].items():
39+
if challenge_type in domain_data:
40+
domains.add(domain)
41+
42+
for domain, domain_data in challenge_response['authorizations'].items():
43+
for challenge in domain_data['challenges']:
44+
if challenge['type'] == challenge_type:
45+
domains.add(domain)
46+
47+
return domains
2448

25-
def build_challenges(challenge_response: dict, challenge_type: str) -> dict:
49+
50+
def build_challenges(challenge_response: dict, challenge_type: str, expected_domains: list) -> dict:
51+
"""
52+
try to extract the challenge for all expected_domains from the challenge_data field (first)0
53+
and then from the authorization field, if not found
54+
"""
2655
if challenge_type not in ['http-01', 'dns-01']:
2756
raise AssertionError(f"Unknown challenge_type ({challenge_type})")
2857

29-
if len(challenge_response['challenge_data']) > 0:
30-
return extract_challenges_from_challenge_data(challenge_response, challenge_type)
31-
else:
32-
return extract_challenges_from_authorizations_data(challenge_response, challenge_type)
58+
extracted_challenges = {}
59+
for domain in expected_domains:
60+
extracted_challenges[domain] = {}
61+
if domain in challenge_response['challenge_data']:
62+
extracted_challenges[domain] = extract_challenge_from_challenge_data(domain, challenge_response,
63+
challenge_type)
64+
elif domain in challenge_response['authorizations']:
65+
extracted_challenges[domain] = extract_challenge_from_authorizations_data(domain, challenge_response,
66+
challenge_type)
3367

68+
return extracted_challenges
3469

35-
def extract_challenges_from_challenge_data(challenge_response: dict, challenge_type: str) -> dict:
36-
re = {}
37-
for domain, domain_data in challenge_response['challenge_data'].items():
38-
if challenge_type not in domain_data:
39-
raise AssertionError(f"challenge_type '{challenge_type}' not in 'challenge_data'")
4070

41-
re[domain] = {}
42-
re[domain][challenge_type] = domain_data[challenge_type]
71+
def extract_challenge_from_challenge_data(domain, challenge_response: dict, challenge_type: str) -> dict:
72+
"""
73+
return the challenge data fields (record, resource, resource_value) out of the challenge_data field for domain
74+
"""
75+
if challenge_type not in challenge_response['challenge_data'][domain]:
76+
raise AssertionError(f"challenge_type '{challenge_type}' not in 'challenge_data'")
4377

44-
return re
78+
return {challenge_type: challenge_response['challenge_data'][domain][challenge_type]}
4579

4680

47-
def extract_challenges_from_authorizations_data(challenge_response: dict, challenge_type: str):
48-
re = {}
49-
for domain, domain_data in challenge_response['authorizations'].items():
50-
if 'challenges' not in challenge_response['authorizations'][domain]:
51-
raise AssertionError(f"'challenges' missing for '{domain}' in API response")
81+
def extract_challenge_from_authorizations_data(domain, challenge_response: dict, challenge_type: str) -> dict:
82+
"""
83+
return the challenge data fields from the authorizations field - note that the format is different from challange_data:
84+
the authorizations field specifies the challenges with their type in a list instead of a dict
85+
"""
86+
if 'challenges' not in challenge_response['authorizations'][domain]:
87+
raise AssertionError(f"'challenges' missing in 'authorizations' field of domain '{domain}'")
5288

53-
for challenge in challenge_response['authorizations'][domain]['challenges']:
54-
if challenge['type'] == challenge_type:
55-
re[domain] = {}
56-
if challenge_type == 'dns-01':
57-
re[domain][challenge_type] = build_data_from_authorizations_dns01(challenge['token'], domain)
58-
elif challenge_type == 'http-01':
59-
re[domain][challenge_type] = build_data_from_authorizations_http01(challenge['token'], domain)
60-
return re
89+
for challenge in challenge_response['authorizations'][domain]['challenges']:
90+
if challenge['type'] == challenge_type:
91+
if challenge_type == 'dns-01':
92+
return {challenge_type: build_data_from_authorizations_dns01(challenge['token'], domain)}
93+
elif challenge_type == 'http-01':
94+
return {challenge_type: build_data_from_authorizations_http01(challenge['token'], domain)}
95+
96+
raise AssertionError(f"challenge_type '{challenge_type}' not in 'authorizations'")
6197

6298

6399
def build_data_from_authorizations_dns01(token: str, domain: str) -> dict:
100+
"""
101+
build the fields that one would expect out of challenge_data for data coming out of authorizations (for DNS-01)
102+
"""
64103
return {
65104
"record": f"_acme-challenge.{domain}",
66105
"resource": "_acme-challenge",
@@ -69,6 +108,9 @@ def build_data_from_authorizations_dns01(token: str, domain: str) -> dict:
69108

70109

71110
def build_data_from_authorizations_http01(token: str, domain: str) -> dict:
111+
"""
112+
build the fields that one would expect out of challange_data for data coming out of authorizations (for HTTP-01)
113+
"""
72114
return {
73115
"resource": f".well-known/acme-challenge/{token}",
74116
"resource_value": token

tests/integration/targets/acme_letsencrypt/find-challenge-filter-plugin.yml

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
ansible.builtin.set_fact:
7777
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('dns-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
7878
register: set_fact_result
79-
- name: Test assert
79+
- name: Test assert TEST-01
8080
ansible.builtin.assert:
8181
that:
8282
- "set_fact_result is not failed"
@@ -93,22 +93,22 @@
9393
ansible.builtin.set_fact:
9494
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('http-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
9595
register: set_fact_result
96-
- name: Test assert
96+
- name: Test assert TEST-02
9797
ansible.builtin.assert:
9898
that:
9999
- "set_fact_result is not failed"
100100
- "'blahtest1.example.org' in test_challenge and 'blahtest2.example.org' in test_challenge"
101101
- "data_with_challenge_data['challenge_data']['blahtest1.example.org']['http-01']['resource'] in test_challenge['blahtest1.example.org']['http-01'].resource"
102102
- "data_with_challenge_data['challenge_data']['blahtest1.example.org']['http-01']['resource_value'] in test_challenge['blahtest1.example.org']['http-01'].resource_value"
103103

104-
- name: TEST-03-1 default / dns-01 / expect less then provided
104+
- name: TEST-03-1 default / dns-01 / expect less then provided by response
105105
block:
106106
- name: Set test_challenge
107107
ignore_errors: true
108108
ansible.builtin.set_fact:
109109
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('dns-01', ['blahtest1.example.org']) }}"
110110
register: set_fact_result
111-
- name: Test assert
111+
- name: Test assert TEST-03-1
112112
ansible.builtin.assert:
113113
that:
114114
- "set_fact_result is failed"
@@ -121,20 +121,20 @@
121121
ansible.builtin.set_fact:
122122
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('dns-01', ['blahtest1.example.org', 'blahtest2.example.org', 'one.more.is.one.too.many']) }}"
123123
register: set_fact_result
124-
- name: Test assert
124+
- name: Test assert TEST-03-2
125125
ansible.builtin.assert:
126126
that:
127127
- "set_fact_result is failed"
128128
- "'Expected dns-01 challenges for the following domains not found: one.more.is.one.too.many' in set_fact_result.msg"
129129

130-
- name: TEST-04 with_authorizations / dns-01 / 2 domains / data_with_challenge_data
130+
- name: TEST-04-1 with_authorizations / dns-01 / 2 domains / data_with_challenge_data
131131
block:
132132
- name: Set test_challenge
133133
ignore_errors: true
134134
ansible.builtin.set_fact:
135135
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('dns-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
136136
register: set_fact_result
137-
- name: Test assert
137+
- name: Test assert TEST-04-1
138138
ansible.builtin.assert:
139139
that:
140140
- "set_fact_result is not failed"
@@ -143,14 +143,14 @@
143143
- "data_with_challenge_data['challenge_data']['blahtest1.example.org']['dns-01']['resource'] in test_challenge['blahtest1.example.org']['dns-01'].resource"
144144
- "data_with_challenge_data['challenge_data']['blahtest1.example.org']['dns-01']['resource_value'] in test_challenge['blahtest1.example.org']['dns-01'].resource_value"
145145

146-
- name: TEST-04 with_authorizations / http-01 / 2 domains / data_with_challenge_data
146+
- name: TEST-04-2 with_authorizations / http-01 / 2 domains / data_with_challenge_data
147147
block:
148148
- name: Set test_challenge
149149
ignore_errors: true
150150
ansible.builtin.set_fact:
151151
test_challenge: "{{ data_with_challenge_data | telekom_mms.acme.find_challenges('http-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
152152
register: set_fact_result
153-
- name: Test assert
153+
- name: Test assert TEST-04-2
154154
ansible.builtin.assert:
155155
that:
156156
- "set_fact_result is not failed"
@@ -165,7 +165,7 @@
165165
ansible.builtin.set_fact:
166166
test_challenge: "{{ data_without_challenge_data | telekom_mms.acme.find_challenges('dns-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
167167
register: set_fact_result
168-
- name: Test assert
168+
- name: Test assert TEST-05
169169
ansible.builtin.assert:
170170
that:
171171
- "set_fact_result is not failed"
@@ -179,7 +179,7 @@
179179
ansible.builtin.set_fact:
180180
test_challenge: "{{ data_without_challenge_data | telekom_mms.acme.find_challenges('http-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
181181
register: set_fact_result
182-
- name: Test assert
182+
- name: Test assert TEST-06
183183
ansible.builtin.assert:
184184
that:
185185
- "set_fact_result is not failed"
@@ -193,7 +193,74 @@
193193
ansible.builtin.set_fact:
194194
test_challenge: "{{ data_without_challenge_data | telekom_mms.acme.find_challenges('http-01', ['blahtest1.example.org']) }}"
195195
register: set_fact_result
196-
- name: Test assert
196+
- name: Test assert TEST-07
197197
ansible.builtin.assert:
198198
that:
199199
- "set_fact_result is failed"
200+
201+
- name: TEST-08 with_mixed_challenge_and_authorizations / http-01
202+
vars:
203+
# blahtest1 is "valid" and therefore can't be found in the "challenge_data"
204+
data_with_mixed_challenge_data:
205+
account_uri: https://ACME.with_authorizations.de/v2/...
206+
authorizations:
207+
blahtest1.example.org:
208+
challenges:
209+
- status: valid
210+
token: Uwqt4U_l6lp04J2KW5nvgJ6LMPXvSrhr
211+
type: dns-01
212+
url: https://ACME.with_authorizations.de/v2/...
213+
- status: valid
214+
token: ukTTEZIqv_KM9J0iEnysAaiiO31-Rpxg
215+
type: http-01
216+
url: https://ACME.with_authorizations.de/v2/....
217+
expires: '2025-03-26T09:00:45Z'
218+
identifier:
219+
type: dns
220+
value: blahtest1.example.org
221+
status: pending
222+
uri: https://ACME.with_authorizations.de/v2/...
223+
blahtest2.example.org:
224+
challenges:
225+
- status: pending
226+
token: WfZ7M6gF63LZjxyqhXKyDkb3pg2NlvjFKHFTOdgd
227+
type: dns-01
228+
url: https://ACME.with_authorizations.de/v2/...
229+
- status: pending
230+
token: ALKOBSIHfEBtdeDVeGj3zROeyECNNry9CD4pqwci
231+
type: http-01
232+
url: https://ACME.with_authorizations.de/v2/....
233+
expires: '2025-03-26T09:00:45Z'
234+
identifier:
235+
type: dns
236+
value: blahtest2.example.org
237+
status: pending
238+
uri: https://ACME.with_authorizations.de/v2/...
239+
cert_days: -1
240+
challenge_data:
241+
blahtest2.example.org:
242+
dns-01:
243+
record: _acme-challenge.blahtest2.example.org
244+
resource: _acme-challenge
245+
resource_value: eksnHDhxBOzrTlOgiDzKlUXZtccjSwKIhc
246+
http-01:
247+
resource: .well-known/acme-challenge/ukTTEZIqv_KM9J0iEnysAaiiO31-Rpxg
248+
resource_value: 8fV3Wc5hIqxMohz4llcqLqHuCv7WriMrgWYRogPypoTY5KRT84sfeU.8fV3Wc5hIqxMohz4llcqLqHuCv7WriMrgWYRogPypoTY5KRT84sfeU
249+
challenge_data_dns:
250+
_acme-challenge.blahtest1.example.org:
251+
- bob4Yd3_SVv-7JET__3G5ZDVWaeGIOxAQmRf3SURbSY
252+
_acme-challenge.blahtest2.example.org:
253+
- eksnHDhxBOzrTlOgiDzKlUXZtccjSwKIhc
254+
block:
255+
- name: Set test_challenge
256+
ignore_errors: true
257+
ansible.builtin.set_fact:
258+
test_challenge: "{{ data_with_mixed_challenge_data | telekom_mms.acme.find_challenges('http-01', ['blahtest1.example.org', 'blahtest2.example.org']) }}"
259+
register: set_fact_result
260+
- name: Test assert TEST-08
261+
ansible.builtin.assert:
262+
that:
263+
- "set_fact_result is not failed"
264+
- "'blahtest1.example.org' in test_challenge and 'blahtest2.example.org' in test_challenge"
265+
- "data_with_mixed_challenge_data['authorizations']['blahtest1.example.org']['challenges'][1]['token'] in test_challenge['blahtest1.example.org']['http-01'].resource_value"
266+
- "data_with_mixed_challenge_data['challenge_data']['blahtest2.example.org']['http-01'].resource_value in test_challenge['blahtest2.example.org']['http-01'].resource_value"

0 commit comments

Comments
 (0)