From 2b14a0d4862875c20265e6ba05b82f700b12c84d Mon Sep 17 00:00:00 2001 From: Hoel IRIS Date: Wed, 23 Oct 2024 14:52:02 +0200 Subject: [PATCH] feat: Handle CouchDB HTTP 403 on all routes Since CouchDB v3.4.0, there has been a new "Lockout" feature, i.e., a rate limit on tuples (IP, login) after multiple authentication failures. It's highlighted in the release note: https://docs.couchdb.org/en/stable/whatsnew/3.4.html#id4 (see the second to last bullet point). As the following upstream discussion shows, this CouchDB feature adds a new case of HTTP 403 possible on all routes: https://github.com/apache/couchdb/issues/5315#issuecomment-2427009998 This commit catches the 403 on all routes. As some routes were already catching 403 for other reasons, the exception message on these routes is changed from their previous message to `"Access forbidden: {reason}"` where `reason` is either the `reason` returned by CouchDB in the JSON body of the answer, or if it doesn't exist, by the `message` of aiohttp ClientResponseError. I manually tested a non-stream route with `await couchdb.info()`, it returns the following: ``` > await couchdb.info() ... aiocouch.exception.UnauthorizedError: Invalid credentials > await couchdb.info() # <=== Lockout ... aiocouch.exception.ForbiddenError: Access forbidden: Account is temporarily locked due to multiple authentication failures ``` --- aiocouch/exception.py | 6 +++++ aiocouch/remote.py | 59 ++++++++++++++++++++++++++++------------- tests/test_exception.py | 9 +++---- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/aiocouch/exception.py b/aiocouch/exception.py index e6947bb..04b2533 100644 --- a/aiocouch/exception.py +++ b/aiocouch/exception.py @@ -143,6 +143,12 @@ def raise_for_endpoint( message_input = {} + with suppress(AttributeError): + message_input["reason"] = endpoint.failure_reason + with suppress(AttributeError): + message_input["reason"] = endpoint._remote.failure_reason + message_input["reason"] = message_input.get("reason", exception.message) + with suppress(AttributeError): message_input["id"] = endpoint.id message_input["endpoint"] = endpoint.endpoint diff --git a/aiocouch/remote.py b/aiocouch/remote.py index c5fa91b..9743e65 100644 --- a/aiocouch/remote.py +++ b/aiocouch/remote.py @@ -97,6 +97,7 @@ def __init__( auth = aiohttp.BasicAuth(user, password, "utf-8") if user and password else None headers = {"Cookie": "AuthSession=" + cookie} if cookie else None self._http_session = aiohttp.ClientSession(headers=headers, auth=auth, **kwargs) + self.failure_reason = None async def _get(self, path: str, params: Optional[JsonDict] = None) -> RequestResult: return await self._request("GET", path, params=params) @@ -160,6 +161,12 @@ async def _request( async with self._http_session.request( method, url=f"{self._server}{path}", **kwargs ) as resp: + if not resp.ok: + # Save the reason for later if any + try: + self.failure_reason = (await resp.json())["reason"] + except: + self.failure_reason = None resp.raise_for_status() return ( HTTPResponse(resp), @@ -179,6 +186,12 @@ async def _streamed_request( async with self._http_session.request( method, url=f"{self._server}{path}", **kwargs ) as resp: + if not resp.ok: + # Save the reason for later if any + try: + self.failure_reason = (await resp.json())["reason"] + except: + self.failure_reason = None resp.raise_for_status() async for line in resp.content: @@ -187,6 +200,7 @@ async def _streamed_request( yield json.loads(line) @raises(401, "Invalid credentials") + @raises(403, "Access forbidden: {reason}") async def _all_dbs(self, **params: Any) -> List[str]: _, json = await self._get("/_all_dbs", params) assert not isinstance(json, bytes) @@ -203,12 +217,14 @@ async def close(self) -> None: await asyncio.sleep(0.250 if has_ssl_conn else 0) @raises(401, "Invalid credentials") + @raises(403, "Access forbidden: {reason}") async def _info(self) -> JsonDict: _, json = await self._get("/") assert not isinstance(json, bytes) return json @raises(401, "Authentication failed, check provided credentials.") + @raises(403, "Access forbidden: {reason}") async def _check_session(self) -> RequestResult: return await self._get("/_session") @@ -223,7 +239,7 @@ def endpoint(self) -> str: return f"/{_quote_id(self.id)}" @raises(401, "Invalid credentials") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") async def _exists(self) -> bool: try: await self._remote._head(self.endpoint) @@ -235,7 +251,7 @@ async def _exists(self) -> bool: raise e @raises(401, "Invalid credentials") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Requested database not found ({id})") async def _get(self) -> JsonDict: _, json = await self._remote._get(self.endpoint) @@ -244,6 +260,7 @@ async def _get(self) -> JsonDict: @raises(400, "Invalid database name") @raises(401, "CouchDB Server Administrator privileges required") + @raises(403, "Access forbidden: {reason}") @raises(412, "Database already exists") async def _put(self, **params: Any) -> JsonDict: _, json = await self._remote._put(self.endpoint, params=params) @@ -252,13 +269,14 @@ async def _put(self, **params: Any) -> JsonDict: @raises(400, "Invalid database name or forgotten document id by accident") @raises(401, "CouchDB Server Administrator privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Database doesn't exist or invalid database name ({id})") async def _delete(self) -> None: await self._remote._delete(self.endpoint) @raises(400, "The request provided invalid JSON data or invalid query parameter") @raises(401, "Read permission required") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Invalid database name") @raises(415, "Bad Content-Type value") async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict: @@ -270,7 +288,7 @@ async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict: @raises(400, "The request provided invalid JSON data") @raises(401, "Invalid credentials") - @raises(403, "Write permission required") + @raises(403, "Access forbidden: {reason}") @raises(417, "At least one document was rejected by the validation function") async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict: data["docs"] = docs @@ -280,7 +298,7 @@ async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read permission required") + @raises(403, "Access forbidden: {reason}") @raises(500, "Query execution failed", RuntimeError) async def _find(self, selector: Any, **data: Any) -> JsonDict: data["selector"] = selector @@ -290,6 +308,7 @@ async def _find(self, selector: Any, **data: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Admin permission required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Database not found") @raises(500, "Execution error") async def _index(self, index: JsonDict, **data: Any) -> JsonDict: @@ -299,20 +318,21 @@ async def _index(self, index: JsonDict, **data: Any) -> JsonDict: return json @raises(401, "Invalid credentials") - @raises(403, "Permission required") + @raises(403, "Access forbidden: {reason}") async def _get_security(self) -> JsonDict: _, json = await self._remote._get(f"{self.endpoint}/_security") assert not isinstance(json, bytes) return json @raises(401, "Invalid credentials") - @raises(403, "Permission required") + @raises(403, "Access forbidden: {reason}") async def _put_security(self, doc: JsonDict) -> JsonDict: _, json = await self._remote._put(f"{self.endpoint}/_security", doc) assert not isinstance(json, bytes) return json @generator_raises(400, "Invalid request") + @generator_raises(403, "Access forbidden: {reason}") async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]: if "feed" in params and params["feed"] == "continuous": params.setdefault("heartbeat", True) @@ -329,6 +349,7 @@ async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]: yield result @raises(400, "Invalid database or JSON payload") + @raises(403, "Access forbidden: {reason}") @raises(415, "Bad Content-Type header value") @raises(500, "Internal server error or timeout") async def _purge(self, docs: JsonDict, **params: Any) -> JsonDict: @@ -350,13 +371,13 @@ def endpoint(self) -> str: return f"{self._database.endpoint}/{_quote_id(self.id)}" @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _head(self) -> None: await self._database._remote._head(self.endpoint) @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _info(self) -> JsonDict: response, _ = await self._database._remote._head(self.endpoint) @@ -376,7 +397,7 @@ async def _exists(self) -> bool: @raises(400, "The format of the request or revision was invalid") @raises(401, "Read privilege required for document '{id}'") - @raises(403, "Read privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document {id} was not found") async def _get(self, **params: Any) -> JsonDict: _, json = await self._database._remote._get(self.endpoint, params) @@ -385,7 +406,7 @@ async def _get(self, **params: Any) -> JsonDict: @raises(400, "The format of the request or revision was invalid") @raises(401, "Write privilege required for document '{id}'") - @raises(403, "Write privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, @@ -401,7 +422,7 @@ async def _put( @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{id}'") - @raises(403, "Write privilege required for document '{id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, "Specified revision ({rev}) is not the latest for target document '{id}'" @@ -414,7 +435,7 @@ async def _delete(self, rev: str, **params: Any) -> Tuple[HTTPResponse, JsonDict @raises(400, "Invalid request body or parameters") @raises(401, "Read or write privileges required") - @raises(403, "Read or write privileges required") + @raises(403, "Access forbidden: {reason}") @raises( 404, "Specified database, document ID or revision doesn't exists ({endpoint})" ) @@ -444,7 +465,7 @@ def endpoint(self) -> str: return f"{self._document.endpoint}/{_quote_id(self.id)}" @raises(401, "Read privilege required for document '{document_id}'") - @raises(403, "Read privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") async def _exists(self) -> bool: try: response, _ = await self._document._database._remote._head( @@ -460,7 +481,7 @@ async def _exists(self) -> bool: @raises(400, "Invalid request parameters") @raises(401, "Read privilege required for document '{document_id}'") - @raises(403, "Read privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document '{document_id}' or attachment '{id}' doesn't exists") async def _get(self, **params: Any) -> bytes: response, data = await self._document._database._remote._get_bytes( @@ -472,7 +493,7 @@ async def _get(self, **params: Any) -> bytes: @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{document_id}'") - @raises(403, "Write privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Document '{document_id}' doesn't exists") @raises( 409, "Specified revision {document_rev} is not the latest for target document" @@ -490,7 +511,7 @@ async def _put( @raises(400, "Invalid request body or parameters") @raises(401, "Write privilege required for document '{document_id}'") - @raises(403, "Write privilege required for document '{document_id}'") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database or document ID doesn't exists ({endpoint})") @raises( 409, "Specified revision {document_rev} is not the latest for target document" @@ -519,7 +540,7 @@ def endpoint(self) -> str: @raises(400, "Invalid request") @raises(401, "Read privileges required") - @raises(403, "Read privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database, design document or view is missing") async def _get(self, **params: Any) -> JsonDict: _, json = await self._database._remote._get(self.endpoint, params) @@ -528,7 +549,7 @@ async def _get(self, **params: Any) -> JsonDict: @raises(400, "Invalid request") @raises(401, "Write privileges required") - @raises(403, "Write privileges required") + @raises(403, "Access forbidden: {reason}") @raises(404, "Specified database, design document or view is missing") async def _post(self, keys: List[str], **params: Any) -> JsonDict: _, json = await self._database._remote._post( diff --git a/tests/test_exception.py b/tests/test_exception.py index ee21d60..423d2c2 100644 --- a/tests/test_exception.py +++ b/tests/test_exception.py @@ -27,9 +27,8 @@ class CustomError(Exception): class DummyEndpoint: - @property - def endpoint(self) -> str: - return "endpoint" + def __init__(self) -> None: + self.failure_reason = "a reason" @raises(400, "bad thing") async def raise_bad_request(self) -> NoReturn: @@ -39,7 +38,7 @@ async def raise_bad_request(self) -> NoReturn: async def raise_unauthorized(self) -> NoReturn: raise ClientResponseError(cast(RequestInfo, None), (), status=401) - @raises(403, "bad thing") + @raises(403, "Access forbidden: {reason}") async def raise_forbidden(self) -> NoReturn: raise ClientResponseError(cast(RequestInfo, None), (), status=403) @@ -81,7 +80,7 @@ async def test_raises() -> None: with pytest.raises(UnauthorizedError): await dummy.raise_unauthorized() - with pytest.raises(ForbiddenError): + with pytest.raises(ForbiddenError, match="Access forbidden: a reason"): await dummy.raise_forbidden() with pytest.raises(NotFoundError):