Skip to content

Commit 24d4bd2

Browse files
committed
Fix contradictory POST Allow header behavior (#495)
POST method was incorrectly included in Allow headers for all resources even when it would return 405 Method Not Allowed. This created confusing behavior where POST appeared as allowed but then failed. Fixes #495
1 parent c185419 commit 24d4bd2

File tree

2 files changed

+87
-10
lines changed

2 files changed

+87
-10
lines changed

xandikos/tests/test_webdav.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ def start_response(code, headers):
7373
def lock(self, app, path):
7474
return self._method(app, "LOCK", path)
7575

76+
def options(self, app, path):
77+
return self._method(app, "OPTIONS", path)
78+
7679
def mkcol(self, app, path):
7780
environ = {
7881
"PATH_INFO": path,
@@ -245,13 +248,70 @@ def test_lock_not_allowed(self):
245248
"Allow",
246249
(
247250
"COPY, DELETE, GET, HEAD, MKCOL, MOVE, OPTIONS, "
248-
"POST, PROPFIND, PROPPATCH, PUT, REPORT"
251+
"PROPFIND, PROPPATCH, PUT, REPORT"
249252
),
250253
),
251254
headers,
252255
)
253256
self.assertEqual(b"", contents)
254257

258+
def test_post_allowed_on_collection(self):
259+
"""Test that POST is included in Allow header for collections."""
260+
from ..webdav import Collection
261+
262+
class TestCollection(Collection):
263+
resource_types = Collection.resource_types
264+
265+
def members(self):
266+
return []
267+
268+
def get_member(self, name):
269+
raise KeyError(name)
270+
271+
def delete_member(self, name, etag=None):
272+
raise KeyError(name)
273+
274+
async def create_member(self, name, contents, content_type, requester=None):
275+
return ("new_item", '"new_etag"')
276+
277+
def get_ctag(self):
278+
return "test-ctag"
279+
280+
def destroy(self):
281+
pass
282+
283+
app = self.makeApp({"/collection": TestCollection()}, [])
284+
code, headers, contents = self.options(app, "/collection")
285+
self.assertEqual("200 OK", code)
286+
287+
# Find the Allow header
288+
allow_header = None
289+
for header_name, header_value in headers:
290+
if header_name == "Allow":
291+
allow_header = header_value
292+
break
293+
294+
self.assertIsNotNone(allow_header, "Allow header should be present")
295+
self.assertIn("POST", allow_header, "POST should be allowed on collections")
296+
297+
def test_post_not_allowed_on_resource(self):
298+
"""Test that POST is NOT included in Allow header for regular resources."""
299+
app = self.makeApp({"/resource": Resource()}, [])
300+
code, headers, contents = self.options(app, "/resource")
301+
self.assertEqual("200 OK", code)
302+
303+
# Find the Allow header
304+
allow_header = None
305+
for header_name, header_value in headers:
306+
if header_name == "Allow":
307+
allow_header = header_value
308+
break
309+
310+
self.assertIsNotNone(allow_header, "Allow header should be present")
311+
self.assertNotIn(
312+
"POST", allow_header, "POST should NOT be allowed on regular resources"
313+
)
314+
255315
def test_mkcol_ok(self):
256316
class Backend:
257317
def create_collection(self, relpath):

xandikos/webdav.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,7 +1828,7 @@ def name(self):
18281828
async def handle(self, request, environ, app):
18291829
raise NotImplementedError(self.handle)
18301830

1831-
def allow(self, request):
1831+
async def allow(self, request, environ, app):
18321832
"""Is this method allowed considering the specified request?"""
18331833
return True
18341834

@@ -1851,14 +1851,23 @@ async def handle(self, request, environ, app):
18511851

18521852

18531853
class PostMethod(Method):
1854+
async def allow(self, request, environ, app):
1855+
"""POST is only allowed on collections (for RFC5995 add-member)."""
1856+
unused_href, path, r = app._get_resource_from_environ(request, environ)
1857+
if r is None:
1858+
return False
1859+
return COLLECTION_RESOURCE_TYPE in r.resource_types
1860+
18541861
async def handle(self, request, environ, app):
18551862
# see RFC5995
18561863
new_contents = await _readBody(request)
18571864
unused_href, path, r = app._get_resource_from_environ(request, environ)
18581865
if r is None:
18591866
return _send_not_found(request)
18601867
if COLLECTION_RESOURCE_TYPE not in r.resource_types:
1861-
return _send_method_not_allowed(app._get_allowed_methods(request))
1868+
return _send_method_not_allowed(
1869+
await app._get_allowed_methods(request, environ)
1870+
)
18621871
content_type, params = parse_type(request.content_type)
18631872
try:
18641873
(name, etag) = await r.create_member(
@@ -1912,7 +1921,9 @@ async def handle(self, request, environ, app):
19121921
description=e.description,
19131922
)
19141923
except NotImplementedError:
1915-
return _send_method_not_allowed(app._get_allowed_methods(request))
1924+
return _send_method_not_allowed(
1925+
await app._get_allowed_methods(request, environ)
1926+
)
19161927
else:
19171928
return Response(status="204 No Content", headers=[("ETag", new_etag)])
19181929
content_type = request.content_type
@@ -1922,7 +1933,9 @@ async def handle(self, request, environ, app):
19221933
# RFC 4918 Section 9.7.1: Return 409 Conflict when intermediate collection is missing
19231934
return Response(status=409, reason="Conflict")
19241935
if COLLECTION_RESOURCE_TYPE not in r.resource_types:
1925-
return _send_method_not_allowed(app._get_allowed_methods(request))
1936+
return _send_method_not_allowed(
1937+
await app._get_allowed_methods(request, environ)
1938+
)
19261939
try:
19271940
(new_name, new_etag) = await r.create_member(
19281941
name,
@@ -2466,7 +2479,9 @@ async def handle(self, request, environ, app):
24662479
raise UnsupportedMediaType(base_content_type)
24672480
href, path, resource = app._get_resource_from_environ(request, environ)
24682481
if resource is not None:
2469-
return _send_method_not_allowed(app._get_allowed_methods(request))
2482+
return _send_method_not_allowed(
2483+
await app._get_allowed_methods(request, environ)
2484+
)
24702485
try:
24712486
resource = app.backend.create_collection(path)
24722487
except FileNotFoundError:
@@ -2506,7 +2521,7 @@ async def handle(self, request, environ, app):
25062521
return _send_not_found(request)
25072522
dav_features = app._get_dav_features(r)
25082523
headers.append(("DAV", ", ".join(dav_features)))
2509-
allowed_methods = app._get_allowed_methods(request)
2524+
allowed_methods = await app._get_allowed_methods(request, environ)
25102525
headers.append(("Allow", ", ".join(allowed_methods)))
25112526

25122527
# RFC7231 requires that if there is no response body,
@@ -2690,19 +2705,21 @@ def _get_dav_features(self, resource):
26902705
"quota",
26912706
]
26922707

2693-
def _get_allowed_methods(self, request):
2708+
async def _get_allowed_methods(self, request, environ):
26942709
"""List of supported methods on this endpoint."""
26952710
ret = []
26962711
for name in sorted(self.methods.keys()):
2697-
if self.methods[name].allow(request):
2712+
if await self.methods[name].allow(request, environ, self):
26982713
ret.append(name)
26992714
return ret
27002715

27012716
async def _handle_request(self, request, environ, start_response=None):
27022717
try:
27032718
do = self.methods[request.method]
27042719
except KeyError:
2705-
return _send_method_not_allowed(self._get_allowed_methods(request))
2720+
return _send_method_not_allowed(
2721+
await self._get_allowed_methods(request, environ)
2722+
)
27062723
try:
27072724
return await do.handle(request, environ, self)
27082725
except BadRequestError as e:

0 commit comments

Comments
 (0)