Skip to content

Commit

Permalink
feat: Handle CouchDB HTTP 403 on all routes
Browse files Browse the repository at this point in the history
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:
apache/couchdb#5315 (comment)

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
```
  • Loading branch information
H--o-l committed Oct 23, 2024
1 parent 282faa1 commit 6f9cf4f
Show file tree
Hide file tree
Showing 3 changed files with 50 additions and 25 deletions.
7 changes: 6 additions & 1 deletion aiocouch/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,12 @@ def raise_for_endpoint(
"format a nice exception for you. I'm sorry."
) from exception

message_input = {}
failure_reason = None
if hasattr(endpoint, "failure_reason"):
failure_reason = endpoint.failure_reason
elif hasattr(endpoint, "_remote") and hasattr(endpoint._remote, "failure_reason"):
failure_reason = endpoint._remote.failure_reason
message_input = {"reason": failure_reason or exception.message}

with suppress(AttributeError):
message_input["id"] = endpoint.id
Expand Down
59 changes: 40 additions & 19 deletions aiocouch/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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")

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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}'"
Expand All @@ -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})"
)
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
9 changes: 4 additions & 5 deletions tests/test_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 6f9cf4f

Please sign in to comment.