Defining resource name as integer in vantage6 may give unintended access
Description
vantage6 is privacy preserving federated learning infrastructure. Prior to version 4.0.0, malicious users may try to get access to resources they are not allowed to see, by creating resources with integers as names. One example where this is a risk, is when users define which users are allowed to run algorithms on their node. This may be defined by username or user id. Now, for example, if user id 13 is allowed to run tasks, and an attacker creates a username with username '13', they would be wrongly allowed to run an algorithm. There may also be other places in the code where such a mixup of resource ID or name leads to issues. Version 4.0.0 contains a patch for this issue. The best solution is to check when resources are created or modified, that the resource name always starts with a character.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vantage6PyPI | < 4.0.0 | 4.0.0 |
Affected products
1Patches
1aacfc24548cbMerge pull request #744 from vantage6/feature/request-validation-marshmallow
23 files changed · +856 −294
vantage6-server/tests_server/test_resources.py+32 −44 modified@@ -584,8 +584,8 @@ def test_change_password(self): # test if fails when wrong password is provided result = self.app.patch("/api/password/change", headers=headers, json={ - "current_password": "wrong_password1!", - "new_password": "a_new_password" + "current_password": "Wrong_password1!", + "new_password": "A_new_password1!" }) self.assertEqual(result.status_code, 401) @@ -1438,14 +1438,15 @@ def test_patch_organization_permissions(self): # unknown organization headers = self.create_user_and_login() - results = self.app.patch('/api/organization/9999', headers=headers) + results = self.app.patch('/api/organization/9999', headers=headers, + json={}) self.assertEqual(results.status_code, HTTPStatus.NOT_FOUND) # try to change anything without permissions org = Organization(name="first-name") org.save() results = self.app.patch(f'/api/organization/{org.id}', - headers=headers) + headers=headers, json={}) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) # change as super user @@ -2055,6 +2056,7 @@ def test_create_node_permissions(self): org = Organization(name=str(uuid.uuid1())) col = Collaboration(organizations=[org]) + col.save() org2 = Organization(name=str(uuid.uuid1())) org2.save() @@ -2157,7 +2159,7 @@ def test_delete_node_permissions(self): def test_patch_node_permissions_as_user(self): # test patching non-existant node headers = self.create_user_and_login() - results = self.app.patch("/api/node/9999", headers=headers) + results = self.app.patch("/api/node/9999", headers=headers, json={}) self.assertEqual(results.status_code, HTTPStatus.NOT_FOUND) # test user without any permissions @@ -2166,7 +2168,8 @@ def test_patch_node_permissions_as_user(self): node = Node(organization=org, collaboration=col) node.save() - results = self.app.patch(f"/api/node/{node.id}", headers=headers) + results = self.app.patch(f"/api/node/{node.id}", headers=headers, + json={}) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) # test user with global permissions @@ -2304,9 +2307,13 @@ def test_view_task_permissions_as_node_and_container(self): def test_create_task_permission_as_user(self): # non existant collaboration headers = self.create_user_and_login() - results = self.app.post('/api/task', headers=headers, json={ - "collaboration_id": 9999 - }) + input_ = {'method': 'dummy'} + task_json = { + "collaboration_id": 9999, + "organizations": [{'id': 9999, 'input': input_}], + "image": "some-image" + } + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.NOT_FOUND) # organizations outside of collaboration @@ -2316,10 +2323,9 @@ def test_create_task_permission_as_user(self): col.save() # task without any node created - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], - "collaboration_id": col.id - }) + task_json["organizations"] = [{'id': org.id, 'input': input_}] + task_json["collaboration_id"] = col.id + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.BAD_REQUEST) # node is used implicitly as in further checks, can only create task @@ -2329,31 +2335,24 @@ def test_create_task_permission_as_user(self): org2 = Organization() org2.save() - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org2.id}], 'collaboration_id': col.id - }) + task_json["organizations"] = [{'id': org2.id, 'input': input_}] + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.BAD_REQUEST) # user without any permissions - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], - "collaboration_id": col.id - }) + task_json["organizations"] = [{'id': org.id, 'input': input_}] + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) # user with organization permissions for other organization rule = Rule.get_by_("task", Scope.ORGANIZATION, Operation.CREATE) headers = self.create_user_and_login(rules=[rule]) - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], 'collaboration_id': col.id - }) + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) # user with organization permissions headers = self.create_user_and_login(org, rules=[rule]) - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], 'collaboration_id': col.id - }) + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.CREATED) # user with global permissions but outside of the collaboration. They @@ -2363,21 +2362,9 @@ def test_create_task_permission_as_user(self): # another organization than their own in the same collaboration rule = Rule.get_by_("task", Scope.GLOBAL, Operation.CREATE) headers = self.create_user_and_login(rules=[rule]) - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], 'collaboration_id': col.id - }) + results = self.app.post('/api/task', headers=headers, json=task_json) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) - # test master task - rule = Rule.get_by_("task", Scope.ORGANIZATION, Operation.CREATE) - headers = self.create_user_and_login(org, rules=[rule]) - results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], - 'collaboration_id': col.id, - 'master': True - }) - self.assertEqual(results.status_code, HTTPStatus.CREATED) - # cleanup node.delete() @@ -2391,10 +2378,11 @@ def test_create_task_permissions_as_container(self): parent_res.save() # test wrong image name + input_ = {'method': 'dummy'} headers = self.login_container(collaboration=col, organization=org, task=parent_task) results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], + "organizations": [{'id': org.id, 'input': input_}], 'collaboration_id': col.id, 'image': 'other-image' }) @@ -2407,15 +2395,15 @@ def test_create_task_permissions_as_container(self): node2 = Node(organization=org, collaboration=col2) node2.save() results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], + "organizations": [{'id': org.id, 'input': input_}], 'collaboration_id': col2.id, 'image': 'some-image' }) self.assertEqual(results.status_code, HTTPStatus.UNAUTHORIZED) # test with correct parameters results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], + "organizations": [{'id': org.id, 'input': input_}], 'collaboration_id': col.id, 'image': 'some-image' }) @@ -2425,7 +2413,7 @@ def test_create_task_permissions_as_container(self): parent_res.status = TaskStatus.COMPLETED parent_res.save() results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], + "organizations": [{'id': org.id, 'input': input_}], 'collaboration_id': col.id, 'image': 'some-image' }) @@ -2435,7 +2423,7 @@ def test_create_task_permissions_as_container(self): parent_res.status = TaskStatus.FAILED parent_res.save() results = self.app.post('/api/task', headers=headers, json={ - "organizations": [{'id': org.id}], + "organizations": [{'id': org.id, 'input': input_}], 'collaboration_id': col.id, 'image': 'some-image' })
vantage6-server/vantage6/server/__init__.py+1 −1 modified@@ -43,7 +43,7 @@ from vantage6.server import db from vantage6.cli.context import ServerContext from vantage6.server.model.base import DatabaseSessionManager, Database -from vantage6.server.resource.common._schema import HATEOASModelSchema +from vantage6.server.resource.common.output_schema import HATEOASModelSchema from vantage6.server.permission import RuleNeed, PermissionManager from vantage6.server.globals import ( APPNAME,
vantage6-server/vantage6/server/model/common/utils.py+37 −0 added@@ -0,0 +1,37 @@ +import re + + +def validate_password(pw: str) -> None: + """ + Check if the password meets the password policy requirements + + Parameters + ---------- + pw: str + Password to be validated + + Raises + ------ + ValueError + If the password does not meet the password policy requirements + """ + if len(pw) < 8: + raise ValueError( + "Password too short: use at least 8 characters with mixed " + "lowercase, uppercase, numbers and special characters" + ) + elif len(pw) > 128: + # because long passwords can be used for DoS attacks (long pw + # hashing consumes a lot of resources) + raise ValueError("Password too long: use at most 128 characters") + elif re.search('[0-9]', pw) is None: + raise ValueError("Password should contain at least one number") + elif re.search('[A-Z]', pw) is None: + raise ValueError( + "Password should contain at least one uppercase letter") + elif re.search('[a-z]', pw) is None: + raise ValueError( + "Password should contain at least one lowercase letter") + elif pw.isalnum(): + raise ValueError( + "Password should contain at least one special character")
vantage6-server/vantage6/server/model/user.py+5 −18 modified@@ -1,6 +1,5 @@ from __future__ import annotations import bcrypt -import re import datetime as dt from sqlalchemy import Column, String, Integer, ForeignKey, DateTime @@ -9,6 +8,7 @@ from vantage6.server.model.base import DatabaseSessionManager from vantage6.server.model.authenticatable import Authenticatable from vantage6.server.model.rule import Operation, Rule, Scope +from vantage6.server.model.common.utils import validate_password class User(Authenticatable): @@ -127,23 +127,10 @@ def set_password(self, pw: str) -> str | None: If the new password fails to pass the checks, a message is returned. Else, none is returned """ - if len(pw) < 8: - return ( - "Password too short: use at least 8 characters with mixed " - "lowercase, uppercase, numbers and special characters" - ) - elif len(pw) > 128: - # because long passwords can be used for DoS attacks (long pw - # hashing consumes a lot of resources) - return "Password too long: use at most 128 characters" - elif re.search('[0-9]', pw) is None: - return "Password should contain at least one number" - elif re.search('[A-Z]', pw) is None: - return "Password should contain at least one uppercase letter" - elif re.search('[a-z]', pw) is None: - return "Password should contain at least one lowercase letter" - elif pw.isalnum(): - return "Password should contain at least one special character" + try: + validate_password(pw) + except ValueError as e: + return str(e) self.password = pw self.save()
vantage6-server/vantage6/server/resource/collaboration.py+36 −11 modified@@ -2,17 +2,22 @@ import logging from flask import request, g -from flask_restful import reqparse, Api +from flask_restful import Api from http import HTTPStatus from vantage6.server import db from vantage6.server.resource.common.pagination import Pagination +from vantage6.server.resource.common.input_schema import ( + CollaborationAddNodeSchema, + CollaborationAddOrganizationSchema, + CollaborationInputSchema +) from vantage6.server.permission import ( Scope as S, Operation as P, PermissionManager ) -from vantage6.server.resource.common._schema import ( +from vantage6.server.resource.common.output_schema import ( CollaborationSchema, TaskSchema, OrganizationSchema, @@ -88,6 +93,9 @@ def setup(api: Api, api_base: str, services: dict) -> None: tasks_schema = TaskSchema() org_schema = OrganizationSchema() node_schema = NodeSchemaSimple() +collaboration_input_schema = CollaborationInputSchema() +collaboration_add_organization_schema = CollaborationAddOrganizationSchema() +collaboration_add_node_schema = CollaborationAddNodeSchema() # ----------------------------------------------------------------------------- @@ -295,13 +303,12 @@ def post(self): tags: ["Collaboration"] """ - parser = reqparse.RequestParser() - parser.add_argument('name', type=str, required=True, - help="This field cannot be left blank!") - parser.add_argument('organization_ids', type=int, required=True, - action='append') - parser.add_argument('encrypted', type=int, required=False) - data = parser.parse_args() + data = request.get_json() + # validate request body + errors = collaboration_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST name = data["name"] if db.Collaboration.exists("name", name): @@ -456,8 +463,14 @@ def patch(self, id): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED - # only update fields that are provided data = request.get_json() + # validate request body + errors = collaboration_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + + # only update fields that are provided if "name" in data: name = data["name"] if collaboration.name != name and \ @@ -670,8 +683,14 @@ def post(self, id): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED - # get the organization + # validate request body data = request.get_json() + errors = collaboration_add_organization_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + + # get the organization organization = db.Organization.get(data['id']) if not organization: return {"msg": f"organization with id={id} is not found"}, \ @@ -885,7 +904,13 @@ def post(self, id): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED + # validate request body data = request.get_json() + errors = collaboration_add_node_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + node = db.Node.get(data['id']) if not node: return {"msg": f"node id={data['id']} not found"}, \
vantage6-server/vantage6/server/resource/common/auth_helper.py+4 −6 modified@@ -6,10 +6,8 @@ from flask import request, render_template from flask_mail import Mail - from vantage6.common.globals import APPNAME, MAIN_VERSION_NAME from vantage6.server.globals import DEFAULT_SUPPORT_EMAIL_ADDRESS -from vantage6.server import db from vantage6.server.model.user import User module_name = __name__.split('.')[-1] @@ -18,7 +16,7 @@ def user_login( config: dict, username: str, password: str, mail: Mail -) -> tuple[dict | db.User, HTTPStatus]: +) -> tuple[dict | User, HTTPStatus]: """ Returns user a message in case of failed login attempt. @@ -42,8 +40,8 @@ def user_login( """ log.info(f"Trying to login '{username}'") failed_login_msg = "Failed to login" - if db.User.username_exists(username): - user = db.User.get_by_username(username) + if User.username_exists(username): + user = User.get_by_username(username) password_policy = config.get("password_policy", {}) max_failed_attempts = password_policy.get('max_failed_attempts', 5) inactivation_time = password_policy.get('inactivation_minutes', 15) @@ -72,7 +70,7 @@ def user_login( def notify_user_blocked( - user: db.User, max_n_attempts: int, min_rem: int, mail: Mail, + user: User, max_n_attempts: int, min_rem: int, mail: Mail, config: dict ) -> None: """
vantage6-server/vantage6/server/resource/common/input_schema.py+451 −0 added@@ -0,0 +1,451 @@ +import uuid +import ipaddress + +from marshmallow import ( + Schema, fields, ValidationError, validates, validates_schema +) +from marshmallow.validate import Length, Range, OneOf + +from vantage6.common.task_status import TaskStatus +from vantage6.server.default_roles import DefaultRole +from vantage6.server.model.common.utils import validate_password + +_MAX_LEN_STR_SHORT = 128 +_MAX_LEN_STR_LONG = 1024 +_MAX_LEN_PW = 128 +_MAX_LEN_NAME = 128 + + +def _validate_name(name: str) -> None: + """ + Validate a name field in the request input. + + Parameters + ---------- + name : str + Name to validate. + + Raises + ------ + ValidationError + If the name is empty, too long or numerical + """ + if not len(name): + raise ValidationError('Name cannot be empty') + if name.isnumeric(): + raise ValidationError('Name cannot a number') + if len(name) > _MAX_LEN_NAME: + raise ValidationError( + f'Name cannot be longer than {_MAX_LEN_NAME} characters' + ) + + +def _validate_password(password: str) -> None: + """ + Check if the password is strong enough. + + Parameters + ---------- + password : str + Password to validate. + + Raises + ------ + ValidationError + If the password is not strong enough. + """ + try: + validate_password(password) + except ValueError as e: + raise ValidationError(str(e)) + + +class _OnlyIdSchema(Schema): + """ Schema for validating POST requests that only require an ID field. """ + id = fields.Integer(required=True, validate=Range(min=1)) + + +class _NameValidationSchema(Schema): + """ Schema for validating POST requests with a name field. """ + name = fields.String(required=True) + + @validates('name') + def validate_name(self, name: str): + """ + Validate the name in the input. + + Parameters + ---------- + name : str + Name to validate. + + Raises + ------ + ValidationError + If the name is empty, too long or numerical + """ + _validate_name(name) + + +class _PasswordValidationSchema(Schema): + """ Schema that contains password validation function """ + password = fields.String(required=True) + + @validates('password') + def _validate_password(self, password: str): + """ + Check if the password is strong enough. + + Parameters + ---------- + password : str + Password to validate. + + Raises + ------ + ValidationError + If the password is not strong enough. + """ + _validate_password(password) + + +class ChangePasswordInputSchema(Schema): + """ Schema for validating input for changing a password. """ + # validation for current password is not necessary, as it is checked in the + # authentication process + current_password = fields.String(required=True, + validate=Length(max=_MAX_LEN_PW)) + new_password = fields.String(required=True) + + @validates('new_password') + def validate_password(self, password: str): + """ + Check if the password is strong enough. + + Parameters + ---------- + password : str + Password to validate. + + Raises + ------ + ValidationError + If the password is not strong enough. + """ + _validate_password(password) + + +class CollaborationInputSchema(_NameValidationSchema): + """ Schema for validating input for a creating a collaboration. """ + organization_ids = fields.List(fields.Integer(), required=True) + encrypted = fields.Boolean(required=True) + + @validates('organization_ids') + def validate_organization_ids(self, organization_ids): + """ + Validate the organization ids in the input. + + Parameters + ---------- + organization_ids : list[int] + List of organization ids to validate. + + Raises + ------ + ValidationError + If the organization ids are not valid. + """ + if not all(i > 0 for i in organization_ids): + raise ValidationError('Organization ids must be greater than 0') + if not len(organization_ids) == len(set(organization_ids)): + raise ValidationError('Organization ids must be unique') + if not len(organization_ids): + raise ValidationError('At least one organization id is required') + + +class CollaborationAddOrganizationSchema(_OnlyIdSchema): + """ + Schema for validating requests that add an organization to a collaboration. + """ + pass + + +class CollaborationAddNodeSchema(_OnlyIdSchema): + """ Schema for validating requests that add a node to a collaboration. """ + pass + + +class KillTaskInputSchema(_OnlyIdSchema): + """ Schema for validating input for killing a task. """ + pass + + +class KillNodeTasksInputSchema(_OnlyIdSchema): + """ Schema for validating input for killing tasks on a node. """ + pass + + +class NodeInputSchema(_NameValidationSchema): + """ Schema for validating input for a creating a node. """ + # overwrite name attr as it is not required for a node + name = fields.String(required=False) + collaboration_id = fields.Integer(required=True, validate=Range(min=1)) + organization_id = fields.Integer(validate=Range(min=1)) + ip = fields.String() + clear_ip = fields.Boolean() + + @validates('ip') + def validate_ip(self, ip: str): + """ + Validate IP address in request body. + + Parameters + ---------- + ip : str + IP address to validate. + + Raises + ------ + ValidationError + If the IP address is not valid. + """ + try: + ipaddress.ip_address(ip) + except ValueError: + raise ValidationError('IP address is not valid') + + +class OrganizationInputSchema(_NameValidationSchema): + """ Schema for validating input for a creating an organization. """ + address1 = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + address2 = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + zipcode = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + country = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + domain = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + public_key = fields.String() + + +class PortInputSchema(Schema): + """ Schema for validating input for a creating a port. """ + port = fields.Integer(required=True) + run_id = fields.Integer(required=True, validate=Range(min=1)) + label = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + + @validates('port') + def validate_port(self, port): + """ + Validate the port in the input. + + Parameters + ---------- + port : int + Port to validate. + + Raises + ------ + ValidationError + If the port is not valid. + """ + if not 1 <= port <= 65535: + raise ValidationError('Port must be between 1 and 65535') + + +class RecoverPasswordInputSchema(Schema): + """ Schema for validating input for recovering a password. """ + email = fields.Email() + username = fields.String(validate=Length(max=_MAX_LEN_NAME)) + + @validates_schema + def validate_email_or_username(self, data, **kwargs): + if not ('email' in data or 'username' in data): + raise ValidationError('Email or username is required') + + +class ResetPasswordInputSchema(_PasswordValidationSchema): + """ Schema for validating input for resetting a password. """ + reset_token = fields.String(required=True, + validate=Length(max=_MAX_LEN_STR_LONG)) + + +class Recover2FAInputSchema(_PasswordValidationSchema): + """ Schema for validating input for recovering 2FA. """ + email = fields.Email() + username = fields.String(validate=Length(max=_MAX_LEN_NAME)) + + @validates_schema + def validate_email_or_username(self, data: dict, **kwargs): + """ + Validate the input, which should contain either an email or username. + + Parameters + ---------- + data : dict + The input data. + + Raises + ------ + ValidationError + If the input does not contain an email or username. + """ + if not ('email' in data or 'username' in data): + raise ValidationError('Email or username is required') + + +class Reset2FAInputSchema(Schema): + """ Schema for validating input for resetting 2FA. """ + reset_token = fields.String(required=True, + validate=Length(max=_MAX_LEN_STR_LONG)) + + +class ResetAPIKeyInputSchema(_OnlyIdSchema): + """ Schema for validating input for resetting an API key. """ + pass + + +class RoleInputSchema(_NameValidationSchema): + """ Schema for validating input for creating a role. """ + description = fields.String(validate=Length(max=_MAX_LEN_STR_LONG)) + rules = fields.List(fields.Integer(validate=Range(min=1)), required=True) + organization_id = fields.Integer(validate=Range(min=1)) + + @validates('name') + def validate_name(self, name: str): + """ + Validate that role name is not one of the default roles. + + Parameters + ---------- + name : str + Role name to validate. + + Raises + ------ + ValidationError + If the role name is one of the default roles. + """ + if name in [role for role in DefaultRole]: + raise ValidationError( + 'Role name cannot be one of the default roles' + ) + + +class RunInputSchema(Schema): + """ Schema for validating input for patching an algorithm run. """ + started_at = fields.DateTime() + finished_at = fields.DateTime() + log = fields.String() + result = fields.String() + status = fields.String(validate=OneOf([s.value for s in TaskStatus])) + + +class TaskInputSchema(_NameValidationSchema): + """ Schema for validating input for creating a task. """ + # overwrite name attr as it is not required for a task + name = fields.String(required=False) + description = fields.String(validate=Length(max=_MAX_LEN_STR_LONG)) + image = fields.String(required=True, validate=Length(min=1)) + collaboration_id = fields.Integer(required=True, validate=Range(min=1)) + organizations = fields.List(fields.Dict(), required=True) + databases = fields.List(fields.String()) + + @validates('organizations') + def validate_organizations(self, organizations: list[dict]): + """ + Validate the organizations in the input. + + Parameters + ---------- + organizations : list[dict] + List of organizations to validate. Each organization must have at + least an organization id. + + Raises + ------ + ValidationError + If the organizations are not valid. + """ + if not len(organizations): + raise ValidationError('At least one organization is required') + for organization in organizations: + if 'id' not in organization: + raise ValidationError( + 'Organization id is required for each organization') + + +class TokenUserInputSchema(Schema): + """ Schema for validating input for creating a token for a user. """ + username = fields.String(required=True, validate=Length( + min=1, max=_MAX_LEN_NAME)) + # Note that we don't inherit from _PasswordValidationSchema here and + # don't validate password in case the password does not fulfill the + # password policy. This is e.g. the case with the default root user created + # when the server is started for the first time. + password = fields.String(required=True, validate=Length( + min=1, max=_MAX_LEN_PW)) + mfa_code = fields.String(validate=Length(max=10)) + + +class TokenNodeInputSchema(Schema): + """ Schema for validating input for creating a token for a node. """ + api_key = fields.String(required=True) + + @validates('api_key') + def validate_api_key(self, api_key: str): + """ + Validate the API key in the input. The API key should be a valid UUID + + Parameters + ---------- + api_key : str + API key to validate. + + Raises + ------ + ValidationError + If the API key is not valid. + """ + try: + uuid.UUID(api_key) + except ValueError: + raise ValidationError('API key is not a valid UUID') + + +class TokenAlgorithmInputSchema(Schema): + """ Schema for validating input for creating a token for an algorithm. """ + task_id = fields.Integer(required=True, validate=Range(min=1)) + image = fields.String(required=True, validate=Length(min=1)) + + +class UserInputSchema(_PasswordValidationSchema): + """ Schema for validating input for creating a user. """ + username = fields.String(required=True, validate=Length( + min=1, max=_MAX_LEN_NAME + )) + email = fields.Email(required=True) + firstname = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + lastname = fields.String(validate=Length(max=_MAX_LEN_STR_SHORT)) + organization_id = fields.Integer(validate=Range(min=1)) + roles = fields.List(fields.Integer(validate=Range(min=1))) + rules = fields.List(fields.Integer(validate=Range(min=1))) + + @validates('username') + def validate_username(self, username: str): + """ + Check if the username is appropriate + + Parameters + ---------- + username : str + Username to validate. + + Raises + ------ + ValidationError + If the username is too short, too long or numeric. + """ + _validate_name(username) + + +class VPNConfigUpdateInputSchema(Schema): + """ Schema for validating input for updating a VPN configuration. """ + vpn_config = fields.String(required=True)
vantage6-server/vantage6/server/resource/common/output_schema.py+10 −13 renamed@@ -206,16 +206,6 @@ class TaskIncludedSchema(TaskSchema): runs = fields.Nested('TaskRunSchema', many=True) -# /task/{id}/run -class TaskRunSchema(ResultSchema): - node = fields.Function( - serialize=lambda obj: RunNodeSchema().dump(obj.node, many=False) - ) - ports = fields.Function( - serialize=lambda obj: RunPortSchema().dump(obj.ports, many=True) - ) - - class RunSchema(HATEOASModelSchema): class Meta: model = db.Run @@ -240,6 +230,16 @@ def result_link(obj): } +# /task/{id}/run +class TaskRunSchema(RunSchema): + node = fields.Function( + serialize=lambda obj: RunNodeSchema().dump(obj.node, many=False) + ) + ports = fields.Function( + serialize=lambda obj: RunPortSchema().dump(obj.ports, many=True) + ) + + class RunTaskIncludedSchema(RunSchema): task = fields.Nested('TaskSchema', many=False, exclude=["runs"]) @@ -309,9 +309,6 @@ class Meta: class NodeSchema(HATEOASModelSchema): - # organization = ma.HyperlinkRelated('organization_with_id') - # collaboration = ma.HyperlinkRelated('collaboration_with_id') - organization = fields.Method("organization") collaboration = fields.Method("collaboration") config = fields.Nested('NodeConfigSchema', many=True,
vantage6-server/vantage6/server/resource/common/swagger_templates.py+12 −11 modified@@ -33,28 +33,29 @@ }, "organizations": { "type": "array", - "items": {"type": "integer"}, + "items": {"type": "dictionary"}, "description": ( - "Organization ids in collaboration to create task " - "for" + "List of organizations for who the task is " + "intended. For each organization, the 'id' and " + "'input' fields should be specified." ) }, "databases": { "type": "array", + "items": {"type": "string"}, "description": "Databases to use for this task" - }, - "master": { - "type": "boolean", - "description": ( - "Whether or not this is a master task. Default " - "value is False" - ) } }, "example": { "name": "human-readable-name", "image": "hello-world", - "collaboration_id": 1 + "collaboration_id": 1, + "description": "human-readable-description", + "organizations": [{ + "id": 1, + "input": "input-for-organization-1" + }], + "databases": ["database1", "database2"], }, "required": ["image", "collaboration_id"] },
vantage6-server/vantage6/server/resource/event.py+21 −8 modified@@ -16,6 +16,10 @@ Operation, PermissionManager ) +from vantage6.server.resource.common.input_schema import ( + KillNodeTasksInputSchema, + KillTaskInputSchema +) module_name = logger_name(__name__) log = logging.getLogger(module_name) @@ -84,6 +88,10 @@ def permissions(permissions: PermissionManager) -> None: description="send websocket events to all collaborations") +kill_task_schema = KillTaskInputSchema() +kill_node_tasks_schema = KillNodeTasksInputSchema() + + # ------------------------------------------------------------------------------ # Resources / API's # ------------------------------------------------------------------------------ @@ -132,12 +140,15 @@ def post(self): tags: ["Task"] """ - # obtain task id or node id from request body = request.get_json() - id_ = body.get("id") - if not id_: - return {"msg": "No task id provided!"}, HTTPStatus.BAD_REQUEST + # validate request body + errors = kill_task_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + + id_ = body.get("id") task = db.Task.get(id_) if not task: return {"msg": f"Task id={id_} not found"}, HTTPStatus.NOT_FOUND @@ -213,12 +224,14 @@ def post(self): tags: ["Task"] """ - # obtain task id or node id from request body = request.get_json() - id_ = body.get("id") - if not id_: - return {"msg": "No node id provided!"}, HTTPStatus.BAD_REQUEST + # validate request body + errors = kill_node_tasks_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + id_ = body.get("id") node = db.Node.get(id_) if not node: return {"msg": f"Node id={id_} not found"}, HTTPStatus.NOT_FOUND
vantage6-server/vantage6/server/resource/__init__.py+1 −1 modified@@ -16,7 +16,7 @@ from vantage6.common import logger_name from vantage6.server import db -from vantage6.server.resource.common._schema import HATEOASModelSchema +from vantage6.server.resource.common.output_schema import HATEOASModelSchema from vantage6.server.permission import PermissionManager from vantage6.server.resource.common.pagination import Page
vantage6-server/vantage6/server/resource/node.py+24 −18 modified@@ -4,16 +4,16 @@ from http import HTTPStatus from flask import g, request -from flask_restful import reqparse, Api - +from flask_restful import Api from vantage6.server.resource import with_user_or_node, with_user from vantage6.server.resource import ServicesResources from vantage6.server.resource.common.pagination import Pagination from vantage6.server.permission import (Scope as S, Operation as P, PermissionManager) from vantage6.server import db -from vantage6.server.resource.common._schema import NodeSchema +from vantage6.server.resource.common.output_schema import NodeSchema +from vantage6.server.resource.common.input_schema import NodeInputSchema module_name = __name__.split('.')[-1] @@ -89,6 +89,7 @@ def permissions(permissions: PermissionManager) -> None: # Resources / API's # ------------------------------------------------------------------------------ node_schema = NodeSchema() +node_input_schema = NodeInputSchema() class NodeBase(ServicesResources): @@ -255,10 +256,11 @@ def post(self): description: Collaboration id organization_id: type: integer - description: Organization id + description: Organization id. If not provided, the user's + organization is used name: - type: str - description: Human-readable name, if not profided a name + type: string + description: Human-readable name. If not provided a name is generated responses: @@ -278,22 +280,21 @@ def post(self): tags: ["Node"] """ - parser = reqparse.RequestParser() - parser.add_argument("collaboration_id", type=int, required=True, - help="This field cannot be left blank!") - parser.add_argument("organization_id", type=int, required=False) - parser.add_argument("name", type=str, required=False) - data = parser.parse_args() - - collaboration = db.Collaboration.get(data["collaboration_id"]) + data = request.get_json() + # validate request body + errors = node_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # check that the collaboration exists + collaboration = db.Collaboration.get(data["collaboration_id"]) if not collaboration: return {"msg": f"collaboration id={data['collaboration_id']} " "does not exist"}, HTTPStatus.NOT_FOUND # 404 # check permissions - org_id = data["organization_id"] + org_id = data.get("organization_id", None) user_org_id = g.user.organization.id if not self.r.c_glo.can(): own = not org_id or org_id == user_org_id @@ -318,7 +319,7 @@ def post(self): HTTPStatus.BAD_REQUEST # if no name is provided, generate one - name = data['name'] if data['name'] else \ + name = data['name'] if 'name' in data else \ f"{organization.name} - {collaboration.name} Node" if db.Node.exists("name", name): return { @@ -519,6 +520,13 @@ def patch(self, id): tags: ["Node"] """ + data = request.get_json() + # validate request body + errors = node_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + node = db.Node.get(id) if not node: return {'msg': f'Node id={id} not found!'}, HTTPStatus.NOT_FOUND @@ -531,8 +539,6 @@ def patch(self, id): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED - data = request.get_json() - # update fields if 'name' in data: name = data['name']
vantage6-server/vantage6/server/resource/organization.py+18 −3 modified@@ -13,10 +13,13 @@ Operation as P, PermissionManager ) +from vantage6.server.resource.common.input_schema import ( + OrganizationInputSchema +) from vantage6.server.resource import ( with_user_or_node, only_for, with_user, ServicesResources ) -from vantage6.server.resource.common._schema import ( +from vantage6.server.resource.common.output_schema import ( OrganizationSchema, CollaborationSchema, NodeSchema @@ -107,6 +110,7 @@ def permissions(permissions: PermissionManager) -> None: # Resources / API's # ------------------------------------------------------------------------------ org_schema = OrganizationSchema() +org_input_schema = OrganizationInputSchema() class OrganizationBase(ServicesResources): @@ -278,8 +282,14 @@ def post(self): return {'msg': 'You lack the permissions to do that!'},\ HTTPStatus.UNAUTHORIZED + # validate request body data = request.get_json() - name = data.get('name', '') + errors = org_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + + name = data.get('name') if db.Organization.exists("name", name): return { "msg": f"Organization with name '{name}' already exists!" @@ -420,6 +430,12 @@ def patch(self, id): tags: ["Organization"] """ + # validate request body + data = request.get_json() + errors = org_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST organization = db.Organization.get(id) if not organization: @@ -434,7 +450,6 @@ def patch(self, id): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED - data = request.get_json() name = data.get('name', None) if name: if organization.name != name and \
vantage6-server/vantage6/server/resource/port.py+10 −3 modified@@ -19,7 +19,8 @@ ) from vantage6.server import db from vantage6.server.resource.common.pagination import Pagination -from vantage6.server.resource.common._schema import PortSchema +from vantage6.server.resource.common.output_schema import PortSchema +from vantage6.server.resource.common.input_schema import PortInputSchema from vantage6.server.model import ( Run, AlgorithmPort, @@ -73,6 +74,7 @@ def setup(api: Api, api_base: str, services: dict) -> None: # Schemas port_schema = PortSchema() +port_input_schema = PortInputSchema() # ----------------------------------------------------------------------------- @@ -249,17 +251,22 @@ def post(self): tags: ["VPN"] """ data = request.get_json() + # validate request body + errors = port_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # The only entity that is allowed to algorithm ports is the node where # those algorithms are running. - run_id = data.get('run_id', '') + run_id = data['run_id'] linked_run = g.session.query.query(Run).filter(Run.id == run_id).one() if g.node.id != linked_run.node.id: return {'msg': 'You lack the permissions to do that!'},\ HTTPStatus.UNAUTHORIZED port = AlgorithmPort( - port=data.get('port', ''), + port=data['port'], run_id=run_id, label=data.get('label' ''), )
vantage6-server/vantage6/server/resource/recover.py+63 −41 modified@@ -23,6 +23,14 @@ from vantage6.server.resource.common.auth_helper import ( create_qr_uri, user_login ) +from vantage6.server.resource.common.input_schema import ( + ChangePasswordInputSchema, + RecoverPasswordInputSchema, + ResetPasswordInputSchema, + Recover2FAInputSchema, + Reset2FAInputSchema, + ResetAPIKeyInputSchema +) module_name = logger_name(__name__) log = logging.getLogger(module_name) @@ -93,6 +101,14 @@ def setup(api: Api, api_base: str, services: dict) -> None: ) +recover_pw_schema = RecoverPasswordInputSchema() +reset_pw_schema = ResetPasswordInputSchema() +recover_2fa_schema = Recover2FAInputSchema() +reset_2fa_schema = Reset2FAInputSchema() +reset_api_key_schema = ResetAPIKeyInputSchema() +change_pw_schema = ChangePasswordInputSchema() + + # ------------------------------------------------------------------------------ # Resources / API's # ------------------------------------------------------------------------------ @@ -125,15 +141,16 @@ def post(self): tags: ["Account recovery"] """ - # retrieve user based on email or username body = request.get_json() + # validate request body + errors = reset_pw_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + reset_token = body.get("reset_token") password = body.get("password") - if not reset_token or not password: - return {"msg": "The reset token and/or password is missing!"}, \ - HTTPStatus.BAD_REQUEST - # obtain user try: user_id = decode_token(reset_token)['sub'].get('id') @@ -192,14 +209,17 @@ def post(self): # default return string ret = {"msg": "If the username or email is in our database you " "will soon receive an email."} - - # obtain username/email from request' body = request.get_json() + + # validate request body + errors = recover_pw_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + + # obtain username/email from request username = body.get("username") email = body.get("email") - if not (email or username): - return {"msg": "No username or email provided!"}, \ - HTTPStatus.BAD_REQUEST # find user in the database, if not here we stop! try: @@ -281,12 +301,15 @@ def post(self): """ # retrieve user based on email or username body = request.get_json() - reset_token = body.get("reset_token") - if not reset_token: - return {"msg": "The reset token is missing!"}, \ + + # validate request body + errors = reset_2fa_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ HTTPStatus.BAD_REQUEST # obtain user + reset_token = body.get("reset_token") try: user_id = decode_token(reset_token)['sub'].get('id') except DecodeError: @@ -336,16 +359,18 @@ def post(self): ret = {"msg": "If you sent a correct combination of username/email and" "password, you will soon receive an email."} - # obtain parameters from request' + # obtain parameters from request body = request.get_json() + + # validate request body + errors = recover_2fa_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + username = body.get("username") email = body.get("email") - if not (email or username): - return {"msg": "No username or email provided!"}, \ - HTTPStatus.BAD_REQUEST password = body.get("password") - if not password: - return {"msg": "No password provided!"}, HTTPStatus.BAD_REQUEST # find user in the database, if not here we stop! try: @@ -448,16 +473,15 @@ def patch(self): tags: ["Account recovery"] """ body = request.get_json() + # validate request body + errors = change_pw_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + old_password = body.get("current_password") new_password = body.get("new_password") - if not old_password: - return {"msg": "Your current password is missing"}, \ - HTTPStatus.BAD_REQUEST - elif not new_password: - return {"msg": "Your new password is missing!"}, \ - HTTPStatus.BAD_REQUEST - user = g.user log.debug(f"Changing password for user {user.id}") @@ -514,7 +538,7 @@ def post(self): schema: properties: id: - type: int + type: integer description: ID of node whose API key is to be reset responses: @@ -532,22 +556,20 @@ def post(self): tags: ["Account recovery"] """ - if not request.is_json: - log.warning('Authentication failed because no JSON body was ' - 'provided!') - return {"msg": "Missing JSON in request"}, HTTPStatus.BAD_REQUEST - - # check which node should have its API key modified - id = request.json.get('id', None) - if not id: - msg = "ID missing in JSON body" - log.error(msg) - return {"msg": msg}, HTTPStatus.BAD_REQUEST + body = request.get_json() + + # validate request body + errors = reset_api_key_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST - # find the node - node = db.Node.get(id) + id_ = body['id'] + node = db.Node.get(id_) if not node: - return {'msg': f'Node id={id} is not found!'}, HTTPStatus.NOT_FOUND + return { + 'msg': f'Node id={id_} is not found!' + }, HTTPStatus.NOT_FOUND # check if user is allowed to edit the node if not self.r.e_glo.can():
vantage6-server/vantage6/server/resource/result.py+1 −1 modified@@ -20,7 +20,7 @@ ServicesResources ) from vantage6.server.resource.common.pagination import Pagination -from vantage6.server.resource.common._schema import ( +from vantage6.server.resource.common.output_schema import ( ResultSchema, ResultTaskIncludedSchema )
vantage6-server/vantage6/server/resource/role.py+26 −25 modified@@ -4,7 +4,7 @@ from http import HTTPStatus from flask.globals import request from flask import g -from flask_restful import reqparse, Api +from flask_restful import Api from sqlalchemy import or_ from vantage6.server import db @@ -17,7 +17,8 @@ PermissionManager ) from vantage6.server.model.rule import Operation, Scope -from vantage6.server.resource.common._schema import RoleSchema, RuleSchema +from vantage6.server.resource.common.output_schema import RoleSchema, RuleSchema +from vantage6.server.resource.common.input_schema import RoleInputSchema from vantage6.server.resource.common.pagination import Pagination from vantage6.server.default_roles import DefaultRole @@ -107,6 +108,7 @@ def permissions(permissions: PermissionManager) -> None: # ----------------------------------------------------------------------------- role_schema = RoleSchema() rule_schema = RuleSchema() +role_input_schema = RoleInputSchema() class RoleBase(ServicesResources): @@ -321,19 +323,12 @@ def post(self): tags: ["Role"] """ - parser = reqparse.RequestParser() - parser.add_argument("name", type=str, required=True) - parser.add_argument("description", type=str, required=True) - parser.add_argument("rules", type=int, action='append', required=False) - parser.add_argument("organization_id", type=int, required=False) - data = parser.parse_args() - - # check if role name is allowed (i.e. not a default role name) - if 'name' in data and data['name'] in [role for role in DefaultRole]: - return { - "msg": f"Cannot create role '{data['name']}' as it is a " - "reserved role name." - }, HTTPStatus.BAD_REQUEST + data = request.get_json() + # validate request body + errors = role_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # obtain the requested rules from the DB. rules = [] @@ -353,7 +348,7 @@ def post(self): # set the organization id organization_id = ( data['organization_id'] - if data['organization_id'] else g.user.organization_id + if 'organization_id' in data else g.user.organization_id ) # verify that the organization for which we create a role exists if not db.Organization.get(organization_id): @@ -371,8 +366,9 @@ def post(self): HTTPStatus.UNAUTHORIZED # create the actual role - role = db.Role(name=data["name"], description=data["description"], - rules=rules, organization_id=organization_id) + role = db.Role(name=data.get("name"), + description=data.get("description"), rules=rules, + organization_id=organization_id) role.save() return role_schema.dump(role, many=False), HTTPStatus.CREATED @@ -491,18 +487,23 @@ def patch(self, id): """ data = request.get_json() + # validate request body + errors = role_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + # organization_id cannot be changed in PATCH, only defined in POST + if 'organization_id' in data: + return {'msg': 'Cannot change organization of a role.'}, \ + HTTPStatus.BAD_REQUEST + role = db.Role.get(id) if not role: return {"msg": f"Role with id={id} not found."}, \ HTTPStatus.NOT_FOUND - # check if role name is allowed (i.e. not a default role name) - if 'name' in data and data['name'] in [role for role in DefaultRole]: - return { - "msg": f"Cannot change role name into '{data['name']}' as that" - " is a reserved role name." - }, HTTPStatus.BAD_REQUEST - elif role.name in [role for role in DefaultRole]: + # check if user tries to change name of a default role + if role.name in [role for role in DefaultRole]: return { "msg": f"This role ('{role.name}') is a default role. Its name" " cannot be changed."
vantage6-server/vantage6/server/resource/rule.py+1 −1 modified@@ -13,7 +13,7 @@ ) from vantage6.common import logger_name from vantage6.server import db -from vantage6.server.resource.common._schema import RuleSchema +from vantage6.server.resource.common.output_schema import RuleSchema from vantage6.server.resource.common.pagination import Pagination
vantage6-server/vantage6/server/resource/run.py+12 −2 modified@@ -20,8 +20,9 @@ parse_datetime, ServicesResources ) +from vantage6.server.resource.common.input_schema import RunInputSchema from vantage6.server.resource.common.pagination import Pagination -from vantage6.server.resource.common._schema import ( +from vantage6.server.resource.common.output_schema import ( RunSchema, RunTaskIncludedSchema, ResultSchema ) from vantage6.server.model import ( @@ -63,7 +64,7 @@ def setup(api, api_base, services): methods=('GET',), resource_class_kwargs=services ) - # TODO implement a PATCH method and use it to update the result. Then, + # TODO v4+ implement a PATCH method and use it to update the result. Then, # remove that from patching it in the Run resource. api.add_resource( Result, @@ -78,6 +79,7 @@ def setup(api, api_base, services): run_schema = RunSchema() run_inc_schema = RunTaskIncludedSchema() result_schema = ResultSchema() +run_input_schema = RunInputSchema() # ----------------------------------------------------------------------------- @@ -522,6 +524,9 @@ def patch(self, id): log: type: string description: Task log messages + status: + type: string + description: Status of the task responses: 200: @@ -543,6 +548,11 @@ def patch(self, id): return {'msg': f'Run id={id} not found!'}, HTTPStatus.NOT_FOUND data = request.get_json() + # validate request body + errors = run_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST if run.organization_id != g.node.organization_id: log.warn(
vantage6-server/vantage6/server/resource/task.py+10 −3 modified@@ -16,10 +16,11 @@ Operation as P ) from vantage6.server.resource import only_for, ServicesResources, with_user -from vantage6.server.resource.common._schema import ( +from vantage6.server.resource.common.output_schema import ( TaskSchema, TaskIncludedSchema, ) +from vantage6.server.resource.common.input_schema import TaskInputSchema from vantage6.server.resource.common.pagination import Pagination from vantage6.server.resource.event import kill_task @@ -100,6 +101,7 @@ def permissions(permissions: PermissionManager) -> None: # ------------------------------------------------------------------------------ task_schema = TaskSchema() task_result_schema = TaskIncludedSchema() +task_input_schema = TaskInputSchema() class TaskBase(ServicesResources): @@ -357,6 +359,12 @@ def post(self): tags: ["Task"] """ data = request.get_json() + # validate request body + errors = task_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + collaboration_id = data.get('collaboration_id') collaboration = db.Collaboration.get(collaboration_id) @@ -430,7 +438,6 @@ def post(self): return {"msg": "Container-token is not valid"}, \ HTTPStatus.UNAUTHORIZED - # permissions ok, create task record and TaskDatabase records task = db.Task(collaboration=collaboration, name=data.get('name', ''), description=data.get('description', ''), image=image, @@ -454,7 +461,7 @@ def post(self): task.save() # save the databases that the task uses - databases = data.get('databases', ['default']) + databases = data.get('databases') if not isinstance(databases, list): databases = [databases] for database in databases:
vantage6-server/vantage6/server/resource/token.py+34 −25 modified@@ -26,6 +26,11 @@ from vantage6.server.resource.common.auth_helper import ( user_login, create_qr_uri ) +from vantage6.server.resource.common.input_schema import ( + TokenAlgorithmInputSchema, + TokenNodeInputSchema, + TokenUserInputSchema +) module_name = __name__.split('.')[-1] log = logging.getLogger(module_name) @@ -80,6 +85,11 @@ def setup(api: Api, api_base: str, services: dict) -> None: ) +user_token_input_schema = TokenUserInputSchema() +node_token_input_schema = TokenNodeInputSchema() +algorithm_token_input_schema = TokenAlgorithmInputSchema() + + # ------------------------------------------------------------------------------ # Resources / API's # ------------------------------------------------------------------------------ @@ -123,18 +133,16 @@ def post(self): """ log.debug("Authenticate user using username and password") - if not request.is_json: - log.warning('Authentication failed because no JSON body was ' - 'provided!') - return {"msg": "Missing JSON in request"}, HTTPStatus.BAD_REQUEST + body = request.get_json() + # validate request body + errors = user_token_input_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # Check JSON body - username = request.json.get('username', None) - password = request.json.get('password', None) - if not username and password: - msg = "Username and/or password missing in JSON body" - log.error(msg) - return {"msg": msg}, HTTPStatus.BAD_REQUEST + username = body.get('username') + password = body.get('password') user, code = user_login(self.config, username, password, self.mail) if code != HTTPStatus.OK: # login failed @@ -150,7 +158,7 @@ def post(self): return create_qr_uri(user), HTTPStatus.OK else: # 2nd authentication factor: check the OTP secret of the user - mfa_code = request.json.get('mfa_code') + mfa_code = body.get('mfa_code') if not mfa_code: # note: this is not treated as error, but simply guide # user to also fill in second factor @@ -219,20 +227,16 @@ def post(self): """ log.debug("Authenticate Node using api key") - if not request.is_json: - log.warning('Authentication failed because no JSON body was ' - 'provided!') - return {"msg": "Missing JSON in request"}, HTTPStatus.BAD_REQUEST + body = request.get_json() + # validate request body + errors = node_token_input_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # Check JSON body - api_key = request.json.get('api_key', None) - if not api_key: - msg = "api_key missing in JSON body" - log.error(msg) - return {"msg": msg}, HTTPStatus.BAD_REQUEST - + api_key = request.json.get('api_key') node = db.Node.get_by_api_key(api_key) - if not node: # login failed log.error("Api key is not recognized") return {"msg": "Api key is not recognized!"}, \ @@ -273,10 +277,15 @@ def post(self): """ log.debug("Creating a token for a container running on a node") - data = request.get_json() + body = request.get_json() + # validate request body + errors = algorithm_token_input_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST - task_id = data.get("task_id") - claim_image = data.get("image") + task_id = body.get("task_id") + claim_image = body.get("image") db_task = db.Task.get(task_id) if not db_task:
vantage6-server/vantage6/server/resource/user.py+32 −52 modified@@ -4,7 +4,7 @@ from http import HTTPStatus from flask import g, request -from flask_restful import reqparse, Api +from flask_restful import Api from vantage6.common import logger_name from vantage6.server import db @@ -17,8 +17,9 @@ with_user, ServicesResources ) +from vantage6.server.resource.common.input_schema import UserInputSchema from vantage6.server.resource.common.pagination import Pagination -from vantage6.server.resource.common._schema import UserSchema +from vantage6.server.resource.common.output_schema import UserSchema module_name = logger_name(__name__) @@ -96,6 +97,7 @@ def permissions(permissions: PermissionManager) -> None: # Resources / API's # ------------------------------------------------------------------------------ user_schema = UserSchema() +user_input_schema = UserInputSchema() class UserBase(ServicesResources): @@ -319,18 +321,12 @@ def post(self): tags: ["User"] """ - parser = reqparse.RequestParser() - parser.add_argument("username", type=str, required=True) - parser.add_argument("firstname", type=str, required=True) - parser.add_argument("lastname", type=str, required=True) - # TODO password should be send to the email, rather than setting it - parser.add_argument("password", type=str, required=True) - parser.add_argument("email", type=str, required=True) - parser.add_argument("organization_id", type=int, required=False, - help="This is only used if you're root") - parser.add_argument("roles", type=int, action="append", required=False) - parser.add_argument("rules", type=int, action="append", required=False) - data = parser.parse_args() + data = request.get_json() + # validate request body + errors = user_input_schema.validate(data) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST # check unique constraints if db.User.username_exists(data["username"]): @@ -342,7 +338,7 @@ def post(self): # check if the organization has been provided, if this is the case the # user needs global permissions in case it is not their own organization_id = g.user.organization_id - if data['organization_id']: + if data.get('organization_id'): if data['organization_id'] != organization_id: if self.r.c_glo.can(): # check if organization exists @@ -384,7 +380,7 @@ def post(self): )}, HTTPStatus.UNAUTHORIZED # You can only assign rules that you already have to others. - potential_rules = data["rules"] + potential_rules = data.get("rules") rules = [] if potential_rules: rules = [db.Rule.get(rule) for rule in potential_rules @@ -547,40 +543,29 @@ def patch(self, id): tags: ["User"] """ user = db.User.get(id) - if not user: return {"msg": f"user id={id} not found"}, \ HTTPStatus.NOT_FOUND + data = request.get_json() + # validate request body + errors = user_input_schema.validate(data, partial=True) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + if data.get("password"): + return {"msg": "You cannot change your password here!"}, \ + HTTPStatus.BAD_REQUEST + if not self.r.e_glo.can(): if not (self.r.e_org.can() and user.organization == g.user.organization): if not (self.r.e_own.can() and user == g.user): return {'msg': 'You lack the permission to do that!'}, \ HTTPStatus.UNAUTHORIZED - parser = reqparse.RequestParser() - parser.add_argument("username", type=str, required=False) - parser.add_argument("firstname", type=str, required=False) - parser.add_argument("lastname", type=str, required=False) - parser.add_argument("email", type=str, required=False) - data = parser.parse_args() - - # check if user defined a password, which is deprecated - # FIXME BvB 22-06-29: with time, this check may be removed. Now it is - # here for backwards compatibility (if people have scripts using this, - # this makes them aware something changed) - request_json = request.get_json() - if request_json.get("password"): - return {"msg": "You cannot change your password here!"}, \ - HTTPStatus.BAD_REQUEST - - if data["username"] is not None: - if data["username"] == '': - return { - "msg": "Empty username is not allowed!" - }, HTTPStatus.BAD_REQUEST - elif user.username != data["username"]: + if data.get("username") is not None: + if user.username != data["username"]: if db.User.exists("username", data["username"]): return { "msg": "User with that username already exists" @@ -590,28 +575,23 @@ def patch(self, id): "msg": "You cannot change the username of another user" }, HTTPStatus.BAD_REQUEST user.username = data["username"] - if data["firstname"] is not None: + if data.get("firstname") is not None: user.firstname = data["firstname"] - if data["lastname"] is not None: + if data.get("lastname") is not None: user.lastname = data["lastname"] - if data["email"] is not None: - if data["email"] == '': - return { - "msg": "Empty email is not allowed!" - }, HTTPStatus.BAD_REQUEST - elif (user.email != data["email"] and + if data.get("email") is not None: + if (user.email != data["email"] and db.User.exists("email", data["email"])): return { "msg": "User with that email already exists." }, HTTPStatus.BAD_REQUEST user.email = data["email"] # request parser is awefull with lists - json_data = request.get_json() - if 'roles' in json_data: + if 'roles' in data: # validate that these roles exist roles = [] - for role_id in json_data['roles']: + for role_id in data['roles']: role = db.Role.get(role_id) if not role: return {'msg': f'Role={role_id} can not be found!'}, \ @@ -653,10 +633,10 @@ def patch(self, id): user.roles = roles - if 'rules' in json_data: + if 'rules' in data: # validate that these rules exist rules = [] - for rule_id in json_data['rules']: + for rule_id in data['rules']: rule = db.Rule.get(rule_id) if not rule: return {'msg': f'Rule={rule_id} can not be found!'}, \
vantage6-server/vantage6/server/resource/vpn.py+15 −7 modified@@ -16,6 +16,9 @@ from vantage6.common import logger_name from vantage6.server.resource import with_node, ServicesResources +from vantage6.server.resource.common.input_schema import ( + VPNConfigUpdateInputSchema +) from vantage6.server.exceptions import ( VPNConfigException, VPNPortalAuthException ) @@ -58,6 +61,9 @@ def setup(api: Api, api_base: str, services: dict) -> None: ) +vpn_config_schema = VPNConfigUpdateInputSchema() + + # ------------------------------------------------------------------------------ # Resources / API's # ------------------------------------------------------------------------------ @@ -168,19 +174,21 @@ def post(self): tags: ["VPN"] """ + body = request.get_json() + + # validate request body + errors = vpn_config_schema.validate(body) + if errors: + return {'msg': 'Request body is incorrect', 'errors': errors}, \ + HTTPStatus.BAD_REQUEST + # check if the VPN server is configured if not self._is_server_configured(): return {'msg': 'This server does not support VPN'}, \ HTTPStatus.NOT_IMPLEMENTED - # retrieve user based on email or username - body = request.get_json() - vpn_config = body.get("vpn_config") - if not vpn_config: - return {"msg": "vpn_config is missing!"}, \ - HTTPStatus.BAD_REQUEST - # refresh keypair by calling EduVPN API + vpn_config = body.get("vpn_config") try: vpn_connector = EduVPNConnector(self.config['vpn_server']) ovpn_config = vpn_connector.refresh_keypair(vpn_config)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-7x94-6g2m-3hp2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-28635ghsaADVISORY
- github.com/pypa/advisory-database/tree/main/vulns/vantage6-node/PYSEC-2023-198.yamlghsaWEB
- github.com/vantage6/vantage6/blob/0682c4288f43fee5bcc72dc448cdd99bd7e57f76/docs/release_notes.rstghsax_refsource_MISCWEB
- github.com/vantage6/vantage6/commit/aacfc24548cbf168579d2e13b2ddaf8ded715d36ghsaWEB
- github.com/vantage6/vantage6/pull/744ghsax_refsource_MISCWEB
- github.com/vantage6/vantage6/security/advisories/GHSA-7x94-6g2m-3hp2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.