This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 33e1909ae4 Improve security and error handling for the internal API
(#40999)
33e1909ae4 is described below
commit 33e1909ae4f9e75dac20c6ea0ceadaddc488183f
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Jul 24 17:53:21 2024 +0200
Improve security and error handling for the internal API (#40999)
There are a few fixes to the internal API error handling that
caused errors when handling permission errors. The internal API
now handles only application/json content type and only requests
that explicitly accept only application/json responses - which is
an extra layer of security that makes CSRF protection not necessary
(though our token validation should already prevent CSRF issues.
The Permission Denied exceptions did not like the exc_info parameter,
so it has been removed. Also in case of auth_manager not initialized
we should return a very generic error message as this is only in
case of standalone internal_api component.
Co-authored-by: Vincent <[email protected]>
---
airflow/api_internal/endpoints/rpc_api_endpoint.py | 12 +++++++---
airflow/api_internal/internal_api_call.py | 1 +
airflow/www/extensions/init_auth_manager.py | 5 ++++
airflow/www/views.py | 5 +++-
.../endpoints/test_rpc_api_endpoint.py | 28 +++++++++++++++++++++-
5 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/airflow/api_internal/endpoints/rpc_api_endpoint.py
b/airflow/api_internal/endpoints/rpc_api_endpoint.py
index 7e655e5b4e..c3d322f01f 100644
--- a/airflow/api_internal/endpoints/rpc_api_endpoint.py
+++ b/airflow/api_internal/endpoints/rpc_api_endpoint.py
@@ -163,6 +163,12 @@ def log_and_build_error_response(message, status):
def internal_airflow_api(body: dict[str, Any]) -> APIResponse:
"""Handle Internal API /internal_api/v1/rpcapi endpoint."""
+ content_type = request.headers.get("Content-Type")
+ if content_type != "application/json":
+ raise PermissionDenied("Expected Content-Type: application/json")
+ accept = request.headers.get("Accept")
+ if accept != "application/json":
+ raise PermissionDenied("Expected Accept: application/json")
auth = request.headers.get("Authorization", "")
signer = JWTSigner(
secret_key=conf.get("core", "internal_api_secret_key"),
@@ -177,11 +183,11 @@ def internal_airflow_api(body: dict[str, Any]) ->
APIResponse:
except BadSignature:
raise PermissionDenied("Bad Signature. Please use only the tokens
provided by the API.")
except InvalidAudienceError:
- raise PermissionDenied("Invalid audience for the request",
exc_info=True)
+ raise PermissionDenied("Invalid audience for the request")
except InvalidSignatureError:
- raise PermissionDenied("The signature of the request was wrong",
exc_info=True)
+ raise PermissionDenied("The signature of the request was wrong")
except ImmatureSignatureError:
- raise PermissionDenied("The signature of the request was sent from the
future", exc_info=True)
+ raise PermissionDenied("The signature of the request was sent from the
future")
except ExpiredSignatureError:
raise PermissionDenied(
"The signature of the request has expired. Make sure that all
components "
diff --git a/airflow/api_internal/internal_api_call.py
b/airflow/api_internal/internal_api_call.py
index 7962b5b590..c01ee141fe 100644
--- a/airflow/api_internal/internal_api_call.py
+++ b/airflow/api_internal/internal_api_call.py
@@ -119,6 +119,7 @@ def internal_api_call(func: Callable[PS, RT]) ->
Callable[PS, RT]:
)
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
method_name}),
}
data = {"jsonrpc": "2.0", "method": method_name, "params": params_json}
diff --git a/airflow/www/extensions/init_auth_manager.py
b/airflow/www/extensions/init_auth_manager.py
index 1c3d399647..f69734ce8a 100644
--- a/airflow/www/extensions/init_auth_manager.py
+++ b/airflow/www/extensions/init_auth_manager.py
@@ -65,3 +65,8 @@ def get_auth_manager() -> BaseAuthManager:
"The `init_auth_manager` method needs to be called first."
)
return auth_manager
+
+
+def is_auth_manager_initialized() -> bool:
+ """Return whether the auth manager has been initialized."""
+ return auth_manager is not None
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 0e0923aa13..568b4bfe39 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -137,7 +137,7 @@ from airflow.utils.types import NOTSET
from airflow.version import version
from airflow.www import auth, utils as wwwutils
from airflow.www.decorators import action_logging, gzipped
-from airflow.www.extensions.init_auth_manager import get_auth_manager
+from airflow.www.extensions.init_auth_manager import get_auth_manager,
is_auth_manager_initialized
from airflow.www.forms import (
DagRunEditForm,
DateTimeForm,
@@ -688,6 +688,9 @@ def method_not_allowed(error):
def show_traceback(error):
"""Show Traceback for a given error."""
+ if not is_auth_manager_initialized():
+ # this is the case where internal API component is used and auth
manager is not used/initialized
+ return ("Error calling the API", 500)
is_logged_in = get_auth_manager().is_logged_in()
return (
render_template(
diff --git a/tests/api_internal/endpoints/test_rpc_api_endpoint.py
b/tests/api_internal/endpoints/test_rpc_api_endpoint.py
index a453c8f367..8a519db814 100644
--- a/tests/api_internal/endpoints/test_rpc_api_endpoint.py
+++ b/tests/api_internal/endpoints/test_rpc_api_endpoint.py
@@ -123,6 +123,7 @@ class TestRpcApiEndpoint:
mock_test_method.return_value = method_result
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
TEST_METHOD_NAME}),
}
input_data = {
@@ -148,6 +149,7 @@ class TestRpcApiEndpoint:
def test_method_with_exception(self, signer: JWTSigner):
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
TEST_METHOD_NAME}),
}
mock_test_method.side_effect = ValueError("Error!!!")
@@ -162,6 +164,7 @@ class TestRpcApiEndpoint:
UNKNOWN_METHOD = "i-bet-it-does-not-exist"
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
UNKNOWN_METHOD}),
}
data = {"jsonrpc": "2.0", "method": UNKNOWN_METHOD, "params": {}}
@@ -174,6 +177,7 @@ class TestRpcApiEndpoint:
def test_invalid_jsonrpc(self, signer: JWTSigner):
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
TEST_METHOD_NAME}),
}
data = {"jsonrpc": "1.0", "method": TEST_METHOD_NAME, "params": {}}
@@ -194,13 +198,14 @@ class TestRpcApiEndpoint:
with pytest.raises(PermissionDenied, match="Unable to authenticate API
via token."):
self.client.post(
"/internal_api/v1/rpcapi",
- headers={"Content-Type": "application/json"},
+ headers={"Content-Type": "application/json", "Accept":
"application/json"},
data=json.dumps(input_data),
)
def test_invalid_token(self, signer: JWTSigner):
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"Authorization": signer.generate_signed_token({"method":
"WRONG_METHOD_NAME"}),
}
data = {"jsonrpc": "1.0", "method": TEST_METHOD_NAME, "params": {}}
@@ -209,3 +214,24 @@ class TestRpcApiEndpoint:
PermissionDenied, match="Bad Signature. Please use only the tokens
provided by the API."
):
self.client.post("/internal_api/v1/rpcapi", headers=headers,
data=json.dumps(data))
+
+ def test_missing_accept(self, signer: JWTSigner):
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": signer.generate_signed_token({"method":
"WRONG_METHOD_NAME"}),
+ }
+ data = {"jsonrpc": "1.0", "method": TEST_METHOD_NAME, "params": {}}
+
+ with pytest.raises(PermissionDenied, match="Expected Accept:
application/json"):
+ self.client.post("/internal_api/v1/rpcapi", headers=headers,
data=json.dumps(data))
+
+ def test_wrong_accept(self, signer: JWTSigner):
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/html",
+ "Authorization": signer.generate_signed_token({"method":
"WRONG_METHOD_NAME"}),
+ }
+ data = {"jsonrpc": "1.0", "method": TEST_METHOD_NAME, "params": {}}
+
+ with pytest.raises(PermissionDenied, match="Expected Accept:
application/json"):
+ self.client.post("/internal_api/v1/rpcapi", headers=headers,
data=json.dumps(data))