From beca4615d1bc947825a5e5bd8a9275c2a7cbda6a Mon Sep 17 00:00:00 2001 From: erev0s <12457993+erev0s@users.noreply.github.com> Date: Thu, 30 May 2024 10:47:49 +0300 Subject: [PATCH] Oas update (#55) * Updated OAS to include auth and added swagger ui * custom 401 handler for connexion to match rest * update readme --- Dockerfile | 4 +-- README.md | 9 ++++++- api_views/books.py | 18 ++++---------- api_views/users.py | 40 +++++++++++++++--------------- config.py | 12 +++++++-- models/user_model.py | 11 +++++---- openapi_specs/openapi3.yml | 50 +++++++++++++++++++++++++------------- requirements.txt | 2 +- 8 files changed, 84 insertions(+), 62 deletions(-) diff --git a/Dockerfile b/Dockerfile index 83116d0d..f8045763 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.7-alpine as builder +FROM python:3.11-alpine as builder RUN apk --update add bash nano g++ COPY . /vampi WORKDIR /vampi RUN pip install -r requirements.txt # Build a fresh container, copying across files & compiled parts -FROM python:3.7-alpine +FROM python:3.11-alpine COPY . /vampi WORKDIR /vampi COPY --from=builder /usr/local/lib /usr/local/lib diff --git a/README.md b/README.md index fbd3cb4c..027dd0ba 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ VAmPI is a vulnerable API made with Flask and it includes vulnerabilities from t - OpenAPI3 specs and Postman Collection included. - Global switch on/off to have a vulnerable environment or not. - Token-Based Authentication (Adjust lifetime from within app.py) + - Available Swagger UI to directly interact with the API VAmPI's flow of actions is going like this: an unregistered user can see minimal information about the dummy users included in the API. A user can register and then login to be allowed using the token received during login to post a book. For a book posted the data accepted are the title and a secret about that book. Each book is unique for every user and only the owner of the book should be allowed to view the secret. @@ -34,7 +35,7 @@ A quick rundown of the actions included can be seen in the following table: | POST | /books/v1 | Add new book | | GET | /books/v1/{book} | Retrieves book by title along with secret | -For more details you can use a service like the [swagger editor](https://editor.swagger.io) supplying it the OpenAPI specification which can be found in the directory `openapi_specs`. +For more details you can either run VAmPI and visit `http://127.0.0.1:5000/ui/` or use a service like the [swagger editor](https://editor.swagger.io) supplying the OpenAPI specification which can be found in the directory `openapi_specs`. #### List of Vulnerabilities @@ -70,6 +71,12 @@ docker run -p 5000:5000 erev0s/vampi:latest docker-compose up -d ~~~~ +## Available Swagger UI :rocket: +Visit the path `/ui` where you are running the API and a Swagger UI will be available to help you get started! +~~~~ +http://127.0.0.1:5000/ui/ +~~~~ + ## Customizing token timeout and vulnerable environment or not If you would like to alter the timeout of the token created after login or if you want to change the environment **not** to be vulnerable then you can use a few ways depending how you run the application. diff --git a/api_views/books.py b/api_views/books.py index 245af6c5..f153b1ce 100644 --- a/api_views/books.py +++ b/api_views/books.py @@ -1,6 +1,6 @@ import jsonschema -from api_views.users import token_validator +from api_views.users import token_validator, error_message_helper from config import db from api_views.json_schemas import * from flask import jsonify, Response, request, json @@ -9,10 +9,6 @@ from app import vuln -def error_message_helper(msg): - return '{ "status": "fail", "message": "' + msg + '"}' - - def get_all_books(): return_value = jsonify({'Books': Book.get_all_books()}) return return_value @@ -25,12 +21,10 @@ def add_new_book(): except: return Response(error_message_helper("Please provide a proper JSON body."), 400, mimetype="application/json") resp = token_validator(request.headers.get('Authorization')) - if "expired" in resp: - return Response(error_message_helper(resp), 401, mimetype="application/json") - elif "Invalid token" in resp: + if "error" in resp: return Response(error_message_helper(resp), 401, mimetype="application/json") else: - user = User.query.filter_by(username=resp).first() + user = User.query.filter_by(username=resp['sub']).first() # check if user already has this book title book = Book.query.filter_by(user=user, book_title=request_data.get('book_title')).first() @@ -50,9 +44,7 @@ def add_new_book(): def get_by_title(book_title): resp = token_validator(request.headers.get('Authorization')) - if "expired" in resp: - return Response(error_message_helper(resp), 401, mimetype="application/json") - elif "Invalid token" in resp: + if "error" in resp: return Response(error_message_helper(resp), 401, mimetype="application/json") else: if vuln: # Broken Object Level Authorization @@ -67,7 +59,7 @@ def get_by_title(book_title): else: return Response(error_message_helper("Book not found!"), 404, mimetype="application/json") else: - user = User.query.filter_by(username=resp).first() + user = User.query.filter_by(username=resp['sub']).first() book = Book.query.filter_by(user=user, book_title=str(book_title)).first() if book: responseObject = { diff --git a/api_views/users.py b/api_views/users.py index 090c40ef..f788701b 100644 --- a/api_views/users.py +++ b/api_views/users.py @@ -10,7 +10,10 @@ def error_message_helper(msg): - return '{ "status": "fail", "message": "' + msg + '"}' + if isinstance(msg, dict): + return '{ "status": "fail", "message": "' + msg['error'] + '"}' + else: + return '{ "status": "fail", "message": "' + msg + '"}' def get_all_users(): @@ -81,12 +84,14 @@ def login_user(): return Response(json.dumps(responseObject), 200, mimetype="application/json") if vuln: # Password Enumeration if user and request_data.get('password') != user.password: - return Response(error_message_helper("Password is not correct for the given username."), 200, mimetype="application/json") + return Response(error_message_helper("Password is not correct for the given username."), 200, + mimetype="application/json") elif not user: # User enumeration return Response(error_message_helper("Username does not exist"), 200, mimetype="application/json") else: if (user and request_data.get('password') != user.password) or (not user): - return Response(error_message_helper("Username or Password Incorrect!"), 200, mimetype="application/json") + return Response(error_message_helper("Username or Password Incorrect!"), 200, + mimetype="application/json") except jsonschema.exceptions.ValidationError as exc: return Response(error_message_helper(exc.message), 400, mimetype="application/json") except: @@ -105,7 +110,7 @@ def token_validator(auth_header): # if auth_token is valid we get back the username of the user return User.decode_auth_token(auth_token) else: - return "Invalid token" + return {'error': 'Invalid token. Please log in again.'} def update_email(username): @@ -115,12 +120,10 @@ def update_email(username): except: return Response(error_message_helper("Please provide a proper JSON body."), 400, mimetype="application/json") resp = token_validator(request.headers.get('Authorization')) - if "expired" in resp: - return Response(error_message_helper(resp), 401, mimetype="application/json") - elif "Invalid token" in resp: + if "error" in resp: return Response(error_message_helper(resp), 401, mimetype="application/json") else: - user = User.query.filter_by(username=resp).first() + user = User.query.filter_by(username=resp['sub']).first() if vuln: # Regex DoS match = re.search( r"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@{1}([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$", @@ -137,7 +140,8 @@ def update_email(username): } return Response(json.dumps(responseObject), 204, mimetype="application/json") else: - return Response(error_message_helper("Please Provide a valid email address."), 400, mimetype="application/json") + return Response(error_message_helper("Please Provide a valid email address."), 400, + mimetype="application/json") else: regex = '^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$' if (re.search(regex, request_data.get('email'))): @@ -152,16 +156,14 @@ def update_email(username): } return Response(json.dumps(responseObject), 204, mimetype="application/json") else: - return Response(error_message_helper("Please Provide a valid email address."), 400, mimetype="application/json") - + return Response(error_message_helper("Please Provide a valid email address."), 400, + mimetype="application/json") def update_password(username): request_data = request.get_json() resp = token_validator(request.headers.get('Authorization')) - if "expired" in resp: - return Response(error_message_helper(resp), 401, mimetype="application/json") - elif "Invalid token" in resp: + if "error" in resp: return Response(error_message_helper(resp), 401, mimetype="application/json") else: if request_data.get('password'): @@ -173,7 +175,7 @@ def update_password(username): else: return Response(error_message_helper("User Not Found"), 400, mimetype="application/json") else: - user = User.query.filter_by(username=resp).first() + user = User.query.filter_by(username=resp['sub']).first() user.password = request_data.get('password') db.session.commit() responseObject = { @@ -185,16 +187,12 @@ def update_password(username): return Response(error_message_helper("Malformed Data"), 400, mimetype="application/json") - - def delete_user(username): resp = token_validator(request.headers.get('Authorization')) - if "expired" in resp: - return Response(error_message_helper(resp), 401, mimetype="application/json") - elif "Invalid token" in resp: + if "error" in resp: return Response(error_message_helper(resp), 401, mimetype="application/json") else: - user = User.query.filter_by(username=resp).first() + user = User.query.filter_by(username=resp['sub']).first() if user.admin: if bool(User.delete_user(username)): responseObject = { diff --git a/config.py b/config.py index 0313af91..a3f4f7ff 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,11 @@ import os import connexion +from flask import jsonify from flask_sqlalchemy import SQLAlchemy vuln_app = connexion.App(__name__, specification_dir='./openapi_specs') -SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(vuln_app.root_path, 'database/database.db') +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(vuln_app.app.root_path, 'database/database.db') vuln_app.app.config['SQLALCHEMY_DATABASE_URI'] = SQLALCHEMY_DATABASE_URI vuln_app.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False @@ -12,6 +13,13 @@ # start the db db = SQLAlchemy(vuln_app.app) -vuln_app.add_api('openapi3.yml') + +@vuln_app.app.errorhandler(401) +def custom_401(error): + # Custom 401 to match the original response sent by Vampi + response = jsonify({"status": "fail", "message": "Invalid token. Please log in again."}) + response.status_code = 401 + return response +vuln_app.add_api('openapi3.yml') diff --git a/models/user_model.py b/models/user_model.py index bf2e275a..4414038c 100644 --- a/models/user_model.py +++ b/models/user_model.py @@ -7,6 +7,7 @@ from random import randrange from sqlalchemy.sql import text + class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True, unique=True, autoincrement=True) @@ -45,17 +46,17 @@ def encode_auth_token(self, user_id): def decode_auth_token(auth_token): try: payload = jwt.decode(auth_token, vuln_app.app.config.get('SECRET_KEY'), algorithms=["HS256"]) - return payload['sub'] + return payload except jwt.ExpiredSignatureError: - return 'Signature expired. Please log in again.' + return {'error': 'Signature expired. Please log in again.'} except jwt.InvalidTokenError: - return 'Invalid token. Please log in again.' + return {'error': 'Invalid token. Please log in again.'} def json(self): - return{'username': self.username, 'email': self.email} + return {'username': self.username, 'email': self.email} def json_debug(self): - return{'username': self.username, 'password': self.password, 'email': self.email, 'admin': self.admin} + return {'username': self.username, 'password': self.password, 'email': self.email, 'admin': self.admin} @staticmethod def get_all_users(): diff --git a/openapi_specs/openapi3.yml b/openapi_specs/openapi3.yml index 09399092..34737404 100644 --- a/openapi_specs/openapi3.yml +++ b/openapi_specs/openapi3.yml @@ -5,7 +5,13 @@ info: version: '0.1' servers: - url: http://localhost:5000 -components: {} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + x-bearerInfoFunc: models.user_model.User.decode_auth_token paths: /createdb: get: @@ -127,17 +133,17 @@ paths: properties: username: type: string - example: 'John.Doe' + example: 'name1' password: type: string - example: 'password123' + example: 'pass1' email: type: string example: 'user@tempmail.com' required: true responses: '200': - description: Sucessfully created user + description: Successfully created user content: application/json: schema: @@ -169,10 +175,10 @@ paths: properties: username: type: string - example: 'John.Doe' + example: 'name1' password: type: string - example: 'password123' + example: 'pass1' required: true responses: '200': @@ -220,7 +226,7 @@ paths: required: true schema: type: string - example: 'John.Doe' + example: 'name1' responses: '200': description: Successfully display user info @@ -231,10 +237,10 @@ paths: properties: username: type: string - example: 'John.Doe' + example: 'name1' email: type: string - example: 'user@tempmail.com' + example: 'mail1@mail.com' '404': description: User not found content: @@ -251,6 +257,8 @@ paths: example: 'User not found' delete: + security: + - bearerAuth: [ ] tags: - users summary: Deletes user by username (Only Admins) @@ -266,7 +274,7 @@ paths: example: 'name1' responses: '200': - description: Sucessfully deleted user + description: Successfully deleted user content: application/json: schema: @@ -309,6 +317,8 @@ paths: example: 'User not found!' /users/v1/{username}/email: put: + security: + - bearerAuth: [ ] tags: - users summary: Update users email @@ -335,7 +345,7 @@ paths: required: true responses: '204': - description: Sucessfully updated user email + description: Successfully updated user email content: {} '400': description: Invalid request @@ -364,9 +374,11 @@ paths: example: 'fail' message: type: string - example: 'Invalid Token' + example: 'Invalid token. Please log in again.' /users/v1/{username}/password: put: + security: + - bearerAuth: [ ] tags: - users summary: Update users password @@ -393,7 +405,7 @@ paths: required: true responses: '204': - description: Sucessfully updated users password + description: Successfully updated users password content: {} '400': description: Invalid request @@ -422,7 +434,7 @@ paths: example: 'fail' message: type: string - example: 'Invalid Token' + example: 'Invalid token. Please log in again.' /books/v1: get: tags: @@ -456,6 +468,8 @@ paths: - book_title: 'bookTitle47' user: 'admin' post: + security: + - bearerAuth: [ ] tags: - books summary: Add new book @@ -479,7 +493,7 @@ paths: required: true responses: '200': - description: Sucessfully added a book + description: Successfully added a book content: application/json: schema: @@ -519,9 +533,11 @@ paths: example: 'fail' message: type: string - example: 'Invalid Token' + example: 'Invalid token. Please log in again.' /books/v1/{book_title}: get: + security: + - bearerAuth: [ ] tags: - books summary: Retrieves book by title along with secret @@ -569,7 +585,7 @@ paths: example: 'fail' message: type: string - example: 'Invalid Token' + example: 'Invalid token. Please log in again.' '404': description: Book not found content: diff --git a/requirements.txt b/requirements.txt index f30f6787..37b558b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -connexion==2.14.2 +connexion[swagger-ui]==2.14.2 flask==2.2.2 flask-sqlalchemy==3.0.3 jsonschema==4.17.3