diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..4ff0489 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b9c1e..eb5589b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: workflow_call: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c651306..66b193b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,16 +24,16 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: python queries: security-extended,security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:python" @@ -47,15 +47,15 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: javascript-typescript queries: security-extended,security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f3a4e19..3eb1e00 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,6 +11,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" permissions: contents: read @@ -126,7 +127,7 @@ jobs: - name: Upload Trivy results to GitHub Security if: github.event_name != 'pull_request' - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 continue-on-error: true with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed663bb..a1ab014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,7 +135,7 @@ jobs: output: 'trivy-results.sarif' - name: Upload Trivy results to GitHub Security - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/scheduled-security-scan.yml b/.github/workflows/scheduled-security-scan.yml index e58ad69..3dbbc4e 100644 --- a/.github/workflows/scheduled-security-scan.yml +++ b/.github/workflows/scheduled-security-scan.yml @@ -73,7 +73,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Trivy results to GitHub Security - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: 'trivy-results.sarif' diff --git a/backend/app/controllers/base_controller.py b/backend/app/controllers/base_controller.py index d92ccd9..91227d7 100644 --- a/backend/app/controllers/base_controller.py +++ b/backend/app/controllers/base_controller.py @@ -12,6 +12,18 @@ class BaseController: def handle_success(self, data, status_code=200): return jsonify(data), status_code + def require_json(self, request): + """Return a 415 response tuple if Content-Type is not application/json, else None.""" + content_type = getattr(request, "content_type", None) or "" + if "application/json" not in content_type: + return jsonify( + { + "error": "unsupported_media_type", + "message": "Content-Type must be application/json.", + } + ), 415 + return None + def handle_error(self, error, operation_name): error_type = type(error).__name__ msg = str(error) diff --git a/backend/app/controllers/event_controller.py b/backend/app/controllers/event_controller.py index 70b74ce..3016190 100644 --- a/backend/app/controllers/event_controller.py +++ b/backend/app/controllers/event_controller.py @@ -46,9 +46,13 @@ def list_events(self, request): def create_event(self, request): try: + err = self.require_json(request) + if err: + return err + data = request.get_json() - if not data: - raise ValueError("Payload cannot be empty") + if not data or not isinstance(data, dict): + raise ValueError("Payload must be a JSON object") details = data.get("details", {}) if details is not None and not isinstance(details, dict): diff --git a/backend/app/controllers/url_controller.py b/backend/app/controllers/url_controller.py index d7eb209..1af7a70 100644 --- a/backend/app/controllers/url_controller.py +++ b/backend/app/controllers/url_controller.py @@ -15,6 +15,10 @@ def set_config(self, config): def create_url(self, request): try: + err = self.require_json(request) + if err: + return err + idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: cache_key = f"idempotency:url:{idempotency_key}" @@ -23,8 +27,8 @@ def create_url(self, request): return self.handle_success(json.loads(cached_resp), 201) data = request.get_json() - if not data: - raise ValueError("Payload cannot be empty") + if not data or not isinstance(data, dict): + raise ValueError("Payload must be a JSON object") schema = CreateUrlSchema(**data) url = self.url_service.create_url( diff --git a/backend/app/controllers/user_controller.py b/backend/app/controllers/user_controller.py index 21fe67a..e45519a 100644 --- a/backend/app/controllers/user_controller.py +++ b/backend/app/controllers/user_controller.py @@ -18,9 +18,13 @@ def set_config(self, config): def create_user(self, request): try: + err = self.require_json(request) + if err: + return err + data = request.get_json() - if not data: - raise ValueError("Payload cannot be empty") + if not data or not isinstance(data, dict): + raise ValueError("Payload must be a JSON object") schema = CreateUserSchema(**data) user = self.user_service.create_user( diff --git a/backend/app/models/event_model.py b/backend/app/models/event_model.py index 361a620..0d0c20d 100644 --- a/backend/app/models/event_model.py +++ b/backend/app/models/event_model.py @@ -21,7 +21,11 @@ class Event(BaseModel): id = BigAutoField() url_id = ForeignKeyField( - ShortURL, backref="events", column_name="url_id", index=True + ShortURL, + backref="events", + column_name="url_id", + index=True, + on_delete="CASCADE", ) user_id = ForeignKeyField( User, diff --git a/backend/app/routes/link_routes.py b/backend/app/routes/link_routes.py index e23544c..ac363c5 100644 --- a/backend/app/routes/link_routes.py +++ b/backend/app/routes/link_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, current_app, jsonify, redirect +from backend.app.config.errors import ForbiddenError, NotFoundError from backend.app.services.url_service import UrlService links_bp = Blueprint("links", __name__) @@ -16,7 +17,15 @@ def _get_service(): @links_bp.get("/") def follow_short_link(code): - destination = _get_service().resolve_redirect(code) + try: + destination = _get_service().resolve_redirect(code) + except ForbiddenError: + return jsonify( + {"error": "gone", "message": "This link has been deactivated."} + ), 410 + except NotFoundError: + return jsonify({"error": "not_found", "message": "Link not found."}), 404 + if not destination: return jsonify( {"error": "not_found", "message": "Link not found or inactive"} diff --git a/backend/app/validators/schemas.py b/backend/app/validators/schemas.py index bdd34ee..6303370 100644 --- a/backend/app/validators/schemas.py +++ b/backend/app/validators/schemas.py @@ -10,12 +10,24 @@ class CreateUserSchema(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + @field_validator("username", mode="before") + @classmethod + def username_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("username must be a string, not a number or other type") + return v + + @field_validator("email", mode="before") + @classmethod + def email_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("email must be a string, not a number or other type") + return v + @field_validator("email") @classmethod def validate_email(cls, v: str) -> str: - if not re.match( - r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v - ): # TODO: Revisit this regex + if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v): raise ValueError( "value is not a valid email address: An email address must have an @-sign." ) diff --git a/tests/unit/test_controllers.py b/tests/unit/test_controllers.py index 82d1d6e..1ba3932 100644 --- a/tests/unit/test_controllers.py +++ b/tests/unit/test_controllers.py @@ -17,11 +17,20 @@ from backend.app.validators.schemas import CreateUserSchema -def _request(payload=None, *, args=None, headers=None, files=None, bad_json=False): +def _request( + payload=None, + *, + args=None, + headers=None, + files=None, + bad_json=False, + content_type="application/json", +): req = MagicMock() req.args = MultiDict(args or {}) req.headers = headers or {} req.files = files or {} + req.content_type = content_type if bad_json: req.get_json.side_effect = BadRequest("malformed json") else: @@ -465,6 +474,18 @@ def test_create_event_requires_payload(self, app): assert status == 400 assert response.get_json()["error"] == "bad_request" + def test_create_event_rejects_string_payload(self, app): + controller = EventController(MagicMock()) + + with app.app_context(): + response, status = controller.create_event( + _request("just a string, not a chest") + ) + + assert status == 400 + assert response.get_json()["error"] == "bad_request" + controller.event_service.create_event.assert_not_called() + def test_base_controller_handles_pydantic_error(app): controller = UserController(MagicMock())