Skip to content

Commit 8385e2d

Browse files
woodruffwewdurbin
authored andcommitted
Add ExpiryCaveat (pypi#11122)
* warehouse, tests: add and test `ExpiryCaveat` * warehouse, tests: refactor macaroon failure handling Add tests for success/failure conditions in the top-level verifier. * tests: lint and format * macaroons/caveats: remove `_fail` API This was a misdesign. Co-authored-by: Ee Durbin <ewdurbin@gmail.com>
1 parent efcba38 commit 8385e2d

File tree

2 files changed

+157
-27
lines changed

2 files changed

+157
-27
lines changed

tests/unit/macaroons/test_caveats.py

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
# limitations under the License.
1212

1313
import json
14+
import os
15+
import time
1416

1517
import pretend
18+
import pymacaroons
1619
import pytest
1720

1821
from pymacaroons.exceptions import MacaroonInvalidSignatureException
1922

20-
from warehouse.macaroons.caveats import Caveat, InvalidMacaroonError, V1Caveat, Verifier
23+
from warehouse.macaroons.caveats import Caveat, ExpiryCaveat, V1Caveat, Verifier
2124

2225
from ...common.db.packaging import ProjectFactory
2326

@@ -28,10 +31,8 @@ def test_creation(self):
2831
caveat = Caveat(verifier)
2932

3033
assert caveat.verifier is verifier
31-
with pytest.raises(InvalidMacaroonError):
32-
caveat.verify(pretend.stub())
33-
with pytest.raises(InvalidMacaroonError):
34-
caveat(pretend.stub())
34+
assert caveat.verify(pretend.stub()) is False
35+
assert caveat(pretend.stub()) is False
3536

3637

3738
class TestV1Caveat:
@@ -47,8 +48,7 @@ def test_verify_invalid_predicates(self, predicate, result):
4748
verifier = pretend.stub()
4849
caveat = V1Caveat(verifier)
4950

50-
with pytest.raises(InvalidMacaroonError):
51-
caveat(predicate)
51+
assert caveat(predicate) is False
5252

5353
def test_verify_valid_predicate(self):
5454
verifier = pretend.stub()
@@ -62,17 +62,17 @@ def test_verify_project_invalid_context(self):
6262
caveat = V1Caveat(verifier)
6363

6464
predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}}
65-
with pytest.raises(InvalidMacaroonError):
66-
caveat(json.dumps(predicate))
65+
66+
assert caveat(json.dumps(predicate)) is False
6767

6868
def test_verify_project_invalid_project_name(self, db_request):
6969
project = ProjectFactory.create(name="foobar")
7070
verifier = pretend.stub(context=project)
7171
caveat = V1Caveat(verifier)
7272

7373
predicate = {"version": 1, "permissions": {"projects": ["notfoobar"]}}
74-
with pytest.raises(InvalidMacaroonError):
75-
caveat(json.dumps(predicate))
74+
75+
assert caveat(json.dumps(predicate)) is False
7676

7777
def test_verify_project_no_projects_object(self, db_request):
7878
project = ProjectFactory.create(name="foobar")
@@ -83,8 +83,8 @@ def test_verify_project_no_projects_object(self, db_request):
8383
"version": 1,
8484
"permissions": {"somethingthatisntprojects": ["blah"]},
8585
}
86-
with pytest.raises(InvalidMacaroonError):
87-
caveat(json.dumps(predicate))
86+
87+
assert caveat(json.dumps(predicate)) is False
8888

8989
def test_verify_project(self, db_request):
9090
project = ProjectFactory.create(name="foobar")
@@ -95,6 +95,56 @@ def test_verify_project(self, db_request):
9595
assert caveat(json.dumps(predicate)) is True
9696

9797

98+
class TestExpiryCaveat:
99+
@pytest.mark.parametrize(
100+
"predicate",
101+
[
102+
# invalid JSON
103+
"invalid json",
104+
# missing nbf and exp
105+
'{"missing": "values"}',
106+
# nbf and exp present, but null
107+
'{"nbf": null, "exp": null}',
108+
# nbf and exp present, but empty
109+
'{"nbf": "", "exp": ""}',
110+
# valid JSON, but wrong type
111+
"[]",
112+
],
113+
)
114+
def test_verify_invalid_predicates(self, predicate):
115+
verifier = pretend.stub()
116+
caveat = ExpiryCaveat(verifier)
117+
118+
assert caveat(predicate) is False
119+
120+
def test_verify_not_before(self):
121+
verifier = pretend.stub()
122+
caveat = ExpiryCaveat(verifier)
123+
124+
not_before = int(time.time()) + 60
125+
expiry = not_before + 60
126+
predicate = json.dumps({"exp": expiry, "nbf": not_before})
127+
assert caveat(predicate) is False
128+
129+
def test_verify_already_expired(self):
130+
verifier = pretend.stub()
131+
caveat = ExpiryCaveat(verifier)
132+
133+
not_before = int(time.time()) - 10
134+
expiry = not_before - 5
135+
predicate = json.dumps({"exp": expiry, "nbf": not_before})
136+
assert caveat(predicate) is False
137+
138+
def test_verify_ok(self):
139+
verifier = pretend.stub()
140+
caveat = ExpiryCaveat(verifier)
141+
142+
not_before = int(time.time()) - 10
143+
expiry = int(time.time()) + 60
144+
predicate = json.dumps({"exp": expiry, "nbf": not_before})
145+
assert caveat(predicate)
146+
147+
98148
class TestVerifier:
99149
def test_creation(self):
100150
macaroon = pretend.stub()
@@ -108,7 +158,7 @@ def test_creation(self):
108158
assert verifier.principals is principals
109159
assert verifier.permission is permission
110160

111-
def test_verify(self, monkeypatch):
161+
def test_verify_invalid_signature(self, monkeypatch):
112162
verify = pretend.call_recorder(
113163
pretend.raiser(MacaroonInvalidSignatureException)
114164
)
@@ -120,6 +170,53 @@ def test_verify(self, monkeypatch):
120170
verifier = Verifier(macaroon, context, principals, permission)
121171

122172
monkeypatch.setattr(verifier.verifier, "verify", verify)
123-
with pytest.raises(InvalidMacaroonError):
124-
verifier.verify(key)
173+
assert verifier.verify(key) is False
125174
assert verify.calls == [pretend.call(macaroon, key)]
175+
176+
@pytest.mark.parametrize(
177+
["caveats", "valid"],
178+
[
179+
# Both V1 and expiry present and valid.
180+
(
181+
[
182+
{"permissions": "user", "version": 1},
183+
{"exp": int(time.time()) + 3600, "nbf": int(time.time()) - 1},
184+
],
185+
True,
186+
),
187+
# V1 only present and valid.
188+
([{"permissions": "user", "version": 1}], True),
189+
# V1 and expiry present but V1 invalid.
190+
([{"permissions": "bad", "version": 1}], False),
191+
# V1 and expiry present but expiry invalid.
192+
(
193+
[
194+
{"permissions": "user", "version": 1},
195+
{"exp": int(time.time()) + 1, "nbf": int(time.time()) + 3600},
196+
],
197+
False,
198+
),
199+
],
200+
)
201+
def test_verify(self, monkeypatch, caveats, valid):
202+
key = os.urandom(32)
203+
m = pymacaroons.Macaroon(
204+
location="fakelocation",
205+
identifier="fakeid",
206+
key=key,
207+
version=pymacaroons.MACAROON_V2,
208+
)
209+
210+
for caveat in caveats:
211+
m.add_first_party_caveat(json.dumps(caveat))
212+
213+
# Round-trip through serialization to ensure we're not clinging to any state.
214+
serialized_macaroon = m.serialize()
215+
deserialized_macaroon = pymacaroons.Macaroon.deserialize(serialized_macaroon)
216+
217+
context = pretend.stub()
218+
principals = pretend.stub()
219+
permission = pretend.stub()
220+
221+
verifier = Verifier(deserialized_macaroon, context, principals, permission)
222+
assert verifier.verify(key) is valid

warehouse/macaroons/caveats.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# limitations under the License.
1212

1313
import json
14+
import time
1415

1516
import pymacaroons
1617

@@ -24,55 +25,86 @@ class InvalidMacaroonError(Exception):
2425
class Caveat:
2526
def __init__(self, verifier):
2627
self.verifier = verifier
28+
# TODO: Surface this failure reason to the user.
29+
# See: https://github.com/pypa/warehouse/issues/9018
30+
self.failure_reason = None
2731

28-
def verify(self, predicate):
29-
raise InvalidMacaroonError
32+
def verify(self, predicate) -> bool:
33+
return False
3034

3135
def __call__(self, predicate):
3236
return self.verify(predicate)
3337

3438

3539
class V1Caveat(Caveat):
36-
def verify_projects(self, projects):
40+
def verify_projects(self, projects) -> bool:
3741
# First, ensure that we're actually operating in
3842
# the context of a package.
3943
if not isinstance(self.verifier.context, Project):
40-
raise InvalidMacaroonError(
44+
self.failure_reason = (
4145
"project-scoped token used outside of a project context"
4246
)
47+
return False
4348

4449
project = self.verifier.context
4550
if project.normalized_name in projects:
4651
return True
4752

48-
raise InvalidMacaroonError(
53+
self.failure_reason = (
4954
f"project-scoped token is not valid for project '{project.name}'"
5055
)
56+
return False
5157

52-
def verify(self, predicate):
58+
def verify(self, predicate) -> bool:
5359
try:
5460
data = json.loads(predicate)
5561
except ValueError:
56-
raise InvalidMacaroonError("malformatted predicate")
62+
self.failure_reason = "malformatted predicate"
63+
return False
5764

5865
if data.get("version") != 1:
59-
raise InvalidMacaroonError("invalidate version in predicate")
66+
self.failure_reason = "invalid version in predicate"
67+
return False
6068

6169
permissions = data.get("permissions")
6270
if permissions is None:
63-
raise InvalidMacaroonError("invalid permissions in predicate")
71+
self.failure_reason = "invalid permissions in predicate"
72+
return False
6473

6574
if permissions == "user":
6675
# User-scoped tokens behave exactly like a user's normal credentials.
6776
return True
6877

6978
projects = permissions.get("projects")
7079
if projects is None:
71-
raise InvalidMacaroonError("invalid projects in predicate")
80+
self.failure_reason = "invalid projects in predicate"
81+
return False
7282

7383
return self.verify_projects(projects)
7484

7585

86+
class ExpiryCaveat(Caveat):
87+
def verify(self, predicate):
88+
try:
89+
data = json.loads(predicate)
90+
expiry = data["exp"]
91+
not_before = data["nbf"]
92+
except (KeyError, ValueError, TypeError):
93+
self.failure_reason = "malformatted predicate"
94+
return False
95+
96+
if not expiry or not not_before:
97+
self.failure_reason = "missing fields"
98+
return False
99+
100+
now = int(time.time())
101+
if now < not_before or now >= expiry:
102+
self.failure_reason = "token is expired"
103+
return False
104+
105+
return True
106+
107+
76108
class Verifier:
77109
def __init__(self, macaroon, context, principals, permission):
78110
self.macaroon = macaroon
@@ -83,11 +115,12 @@ def __init__(self, macaroon, context, principals, permission):
83115

84116
def verify(self, key):
85117
self.verifier.satisfy_general(V1Caveat(self))
118+
self.verifier.satisfy_general(ExpiryCaveat(self))
86119

87120
try:
88121
return self.verifier.verify(self.macaroon, key)
89122
except (
90123
pymacaroons.exceptions.MacaroonInvalidSignatureException,
91124
Exception, # https://github.com/ecordell/pymacaroons/issues/50
92125
):
93-
raise InvalidMacaroonError("invalid macaroon signature")
126+
return False

0 commit comments

Comments
 (0)