From 93dfdb5119ee83199b27cee8527aa181c35d30f4 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 14:49:44 +0200 Subject: [PATCH 01/30] chore: add CLAUDE.md to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6c4c73e..6df8e27 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ dist/ *.egg-info/ .python-version +# Claude Code +CLAUDE.md + # Project related var/log/ docs/_build/ From e573d5f12eb67d4bd31898cc1d9806287889bc27 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 14:53:51 +0200 Subject: [PATCH 02/30] fix(security): read SECRET_KEY and SECURITY_PASSWORD_SALT from env vars --- docker-compose.yml | 4 ++++ instance/docker.py | 4 ++-- instance/heroku.py | 4 ++-- instance/production.py.sample | 6 ++++-- mosp/bootstrap.py | 22 ++++++++++++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2b715b2..8f0984c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,10 @@ services: - MOSP_CONFIG=docker.py - HOST=0.0.0.0 - PORT=5000 + # Required: set strong random values before deploying + # Generate with: python -c "import secrets; print(secrets.token_hex(32))" + - SECRET_KEY= + - SECURITY_PASSWORD_SALT= command: "./entrypoint.sh" volumes: - .:/mosp:rw diff --git a/instance/docker.py b/instance/docker.py index 1aaca8d..5150ad8 100644 --- a/instance/docker.py +++ b/instance/docker.py @@ -27,8 +27,8 @@ ) SQLALCHEMY_TRACK_MODIFICATIONS = os.getenv("SQLALCHEMY_TRACK_MODIFICATIONS", "0") == "1" -SECRET_KEY = "LCx3BchmHRxFzkEv4BqQJyeXRLXenf" -SECURITY_PASSWORD_SALT = "L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD" +SECRET_KEY = os.environ.get("SECRET_KEY", "") +SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT", "") LOG_PATH = "./var/log/mosp.log" LOG_LEVEL = "info" diff --git a/instance/heroku.py b/instance/heroku.py index 145dbbd..4157f4f 100644 --- a/instance/heroku.py +++ b/instance/heroku.py @@ -8,8 +8,8 @@ SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "").replace("://", "ql://", 1) SQLALCHEMY_TRACK_MODIFICATIONS = False -SECRET_KEY = "SECRET KEY" -SECURITY_PASSWORD_SALT = "SECURITY PASSWORD SALT" +SECRET_KEY = os.environ["SECRET_KEY"] +SECURITY_PASSWORD_SALT = os.environ["SECURITY_PASSWORD_SALT"] SELF_REGISTRATION = True diff --git a/instance/production.py.sample b/instance/production.py.sample index c8b892c..dc02281 100644 --- a/instance/production.py.sample +++ b/instance/production.py.sample @@ -1,3 +1,5 @@ +import os + HOST = "127.0.0.1" PORT = 5000 TESTING = False @@ -15,8 +17,8 @@ SQLALCHEMY_DATABASE_URI = "postgres://{user}:{password}@{host}:{port}/{name}".fo ) SQLALCHEMY_TRACK_MODIFICATIONS = False -SECRET_KEY = "LCx3BchmHRxFzkEv4BqQJyeXRLXenf" -SECURITY_PASSWORD_SALT = "L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD" +SECRET_KEY = os.environ.get("SECRET_KEY", "") +SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT", "") SELF_REGISTRATION = True diff --git a/mosp/bootstrap.py b/mosp/bootstrap.py index 24fedca..b40c975 100644 --- a/mosp/bootstrap.py +++ b/mosp/bootstrap.py @@ -75,6 +75,28 @@ def set_logging( except Exception: application.config.from_pyfile("development.py", silent=False) +_KNOWN_WEAK_KEYS = { + "", + "dev", + "SECRET KEY", + "SECURITY PASSWORD SALT", + "LCx3BchmHRxFzkEv4BqQJyeXRLXenf", + "L8gTsyrpRQEF8jNWQPyvRfv7U5kJkD", # old SECURITY_PASSWORD_SALT default +} + +if not application.config.get("TESTING") and os.environ.get("testing") != "actions": + if application.config.get("SECRET_KEY", "") in _KNOWN_WEAK_KEYS: + raise RuntimeError( + "SECRET_KEY is not set or uses a known insecure default. " + "Set the SECRET_KEY environment variable to a strong random value " + "(e.g. python -c \"import secrets; print(secrets.token_hex(32))\")." + ) + if application.config.get("SECURITY_PASSWORD_SALT", "") in _KNOWN_WEAK_KEYS: + raise RuntimeError( + "SECURITY_PASSWORD_SALT is not set or uses a known insecure default. " + "Set the SECURITY_PASSWORD_SALT environment variable." + ) + # Database and migration db = SQLAlchemy(application) migrate = Migrate(application, db) From 04f99aa3e42ee7788013643ae300a7718608c9b6 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 16:10:11 +0200 Subject: [PATCH 03/30] fix(security): add ownership check on schema edit and delete routes --- instance/development.py | 2 +- mosp/views/decorators.py | 30 ++++++++++++++ mosp/views/schema.py | 4 ++ tests/conftest.py | 62 ++++++++++++++++++++++------- tests/test_schema.py | 86 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 tests/test_schema.py diff --git a/instance/development.py b/instance/development.py index bd95c34..cab72a6 100644 --- a/instance/development.py +++ b/instance/development.py @@ -10,7 +10,7 @@ "port": 5432, } DATABASE_NAME = "mosp" -SQLALCHEMY_DATABASE_URI = "postgres://{user}:{password}@{host}:{port}/{name}".format( +SQLALCHEMY_DATABASE_URI = "postgresql://{user}:{password}@{host}:{port}/{name}".format( name=DATABASE_NAME, **DB_CONFIG_DICT ) SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/mosp/views/decorators.py b/mosp/views/decorators.py index b7edf35..d2d3c0f 100644 --- a/mosp/views/decorators.py +++ b/mosp/views/decorators.py @@ -6,6 +6,7 @@ from mosp.models import Collection from mosp.models import JsonObject from mosp.models import Organization +from mosp.models import Schema def check_object_edit_permission(f): @@ -72,3 +73,32 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function + + +def check_schema_edit_permission(f): + """Check if the authenticated user belongs to the organization that owns + the schema. Mirrors check_object_edit_permission for schema routes.""" + + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + return abort(403) + + schema_id = kwargs.get("schema_id", None) + if schema_id is None: + # creation path — no schema to check yet + return f(*args, **kwargs) + + schema = Schema.query.filter(Schema.id == schema_id).first() + if schema is None: + return abort(404) + + if current_user.is_admin: + return f(*args, **kwargs) + + if schema.org_id not in [org.id for org in current_user.organizations]: + return abort(403) + + return f(*args, **kwargs) + + return decorated_function diff --git a/mosp/views/schema.py b/mosp/views/schema.py index 3ee7aa4..18dbb1f 100644 --- a/mosp/views/schema.py +++ b/mosp/views/schema.py @@ -29,6 +29,7 @@ from mosp.bootstrap import application from mosp.bootstrap import db from mosp.forms import SchemaForm +from mosp.views.decorators import check_schema_edit_permission from mosp.models import Event from mosp.models import JsonObject from mosp.models import Schema @@ -228,6 +229,7 @@ def get_objects(schema_id=None): @schema_bp.route("/create", methods=["GET"]) @schema_bp.route("/edit/", methods=["GET"]) @login_required +@check_schema_edit_permission def form(schema_id=None, org_id=None): """Returns a form in order to edit a schema.""" action = gettext("Create a schema") @@ -302,6 +304,7 @@ def form(schema_id=None, org_id=None): @schema_bp.route("/create", methods=["POST"]) @schema_bp.route("/edit/", methods=["POST"]) @login_required +@check_schema_edit_permission def process_form(schema_id=None): """ "Process the form to edit a schema.""" form = SchemaForm() @@ -359,6 +362,7 @@ def process_form(schema_id=None): @schema_bp.route("/delete/", methods=["GET"]) @login_required +@check_schema_edit_permission def delete(schema_id=None): """ Delete the requested schema. diff --git a/tests/conftest.py b/tests/conftest.py index d13e89b..883dfd7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +import app as _app_module # noqa: F401 — registers all blueprints on the application + from mosp.bootstrap import application from mosp.bootstrap import db as _db @@ -37,19 +39,51 @@ def teardown(): @pytest.fixture(scope="function") def session(db, request): - """Creates a new database session for a test.""" - connection = db.engine.connect() - transaction = connection.begin() - - options = dict(bind=connection, binds={}) - session = db._make_scoped_session(options=options) + """Creates a new database session for a test. + + Data is committed to the real DB so that the Flask test client (which + opens its own DB connection per request) can see it. All rows inserted + during the test are deleted in teardown via a TRUNCATE … CASCADE so that + tests remain isolated from each other. + """ + yield db.session + + # Teardown: remove all rows from every table in reverse dependency order. + db.session.remove() + with db.engine.begin() as conn: + table_names = ", ".join( + '"{}"'.format(t.name) + for t in reversed(db.metadata.sorted_tables) + if t.name != "alembic_version" + ) + if table_names: + conn.execute( + db.text(f"TRUNCATE TABLE {table_names} RESTART IDENTITY CASCADE") + ) - db.session = session - def teardown(): - transaction.rollback() - connection.close() - session.remove() - - request.addfinalizer(teardown) - return session +@pytest.fixture(scope="function") +def client(app, session): + """Test HTTP client with CSRF disabled. + + Depends on ``session`` so that the DB TRUNCATE teardown always runs + *after* the client is closed, keeping test isolation intact. + + In Flask 3.x, ``g`` is scoped to the application context rather than + the request context. Because the ``app`` fixture pushes a single + session-wide application context, ``g._login_user`` (set by + Flask-Login) would otherwise persist across tests. Pushing a fresh + application context here gives every test its own ``g`` so that login + state cannot bleed between tests. + """ + app.config["WTF_CSRF_ENABLED"] = False + # Push a fresh app context so that Flask's g (and g._login_user) is + # isolated to this test, not shared with the session-wide app context. + ctx = app.app_context() + ctx.push() + try: + with app.test_client() as c: + yield c + finally: + ctx.pop() + app.config["WTF_CSRF_ENABLED"] = True diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..56e315e --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,86 @@ +import pytest +from werkzeug.security import generate_password_hash + +from mosp.models import Organization +from mosp.models import Schema +from mosp.models import User + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def make_org(session, name): + org = Organization(name=name, description="test") + session.add(org) + session.commit() + return org + + +def make_user(session, login, email, org=None): + user = User( + login=login, + pwdhash=generate_password_hash("password"), + email=email, + is_active=True, + ) + if org: + user.organizations.append(org) + session.add(user) + session.commit() + return user + + +def make_schema(session, name, org, creator_id=None): + schema = Schema( + name=name, + description="test schema", + json_schema={"type": "object", "properties": {}}, + org_id=org.id, + creator_id=creator_id, + ) + session.add(schema) + session.commit() + return schema + + +def login_as(client, login_name): + return client.post( + "/login", + data={"login": login_name, "password": "password"}, + follow_redirects=True, + ) + + +# ── ownership tests ─────────────────────────────────────────────────────────── + +def test_non_owner_cannot_edit_schema(client, session): + org_a = make_org(session, "OrgA_edit") + org_b = make_org(session, "OrgB_edit") + owner = make_user(session, "owner_e1", "owner_e1@t.local", org=org_a) + make_user(session, "other_e1", "other_e1@t.local", org=org_b) + schema = make_schema(session, "Schema_e1", org_a, creator_id=owner.id) + + login_as(client, "other_e1") + response = client.get(f"/schema/edit/{schema.id}") + assert response.status_code == 403 + + +def test_owner_can_edit_schema(client, session): + org = make_org(session, "OrgA_edit2") + owner = make_user(session, "owner_e2", "owner_e2@t.local", org=org) + schema = make_schema(session, "Schema_e2", org, creator_id=owner.id) + + login_as(client, "owner_e2") + response = client.get(f"/schema/edit/{schema.id}") + assert response.status_code == 200 + + +def test_non_owner_cannot_delete_schema(client, session): + org_a = make_org(session, "OrgA_del") + org_b = make_org(session, "OrgB_del") + owner = make_user(session, "owner_d1", "owner_d1@t.local", org=org_a) + make_user(session, "other_d1", "other_d1@t.local", org=org_b) + schema = make_schema(session, "Schema_d1", org_a, creator_id=owner.id) + + login_as(client, "other_d1") + response = client.get(f"/schema/delete/{schema.id}") + assert response.status_code == 403 From ddf1980d32be49cf68c6332f9261ad5bd604b496 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 16:13:10 +0200 Subject: [PATCH 04/30] fix(config): use postgresql:// scheme in production sample (SQLAlchemy 2.x) --- instance/production.py.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instance/production.py.sample b/instance/production.py.sample index dc02281..36c9c3a 100644 --- a/instance/production.py.sample +++ b/instance/production.py.sample @@ -12,7 +12,7 @@ DB_CONFIG_DICT = { "port": 5432, } DATABASE_NAME = "mosp" -SQLALCHEMY_DATABASE_URI = "postgres://{user}:{password}@{host}:{port}/{name}".format( +SQLALCHEMY_DATABASE_URI = "postgresql://{user}:{password}@{host}:{port}/{name}".format( name=DATABASE_NAME, **DB_CONFIG_DICT ) SQLALCHEMY_TRACK_MODIFICATIONS = False From 6dfbc90b0a2a56b38846a5a802b75b5f734fc9cf Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 16:17:15 +0200 Subject: [PATCH 05/30] fix(security): harden schema permission decorator and add org_id server-side validation - Use current_user.is_organization_member() instead of inline comprehension - Add explicit comment on admin bypass rationale - Clarify is_authenticated guard intent in docstring - Add server-side org_id membership check in process_form create path - Fix import order in schema.py (mosp.views after mosp.models) - Add tests: 404, admin bypass, POST protection (6 tests total) --- mosp/views/decorators.py | 15 ++++++++++-- mosp/views/schema.py | 9 ++++++- tests/test_schema.py | 51 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/mosp/views/decorators.py b/mosp/views/decorators.py index d2d3c0f..2beeedc 100644 --- a/mosp/views/decorators.py +++ b/mosp/views/decorators.py @@ -77,7 +77,15 @@ def decorated_function(*args, **kwargs): def check_schema_edit_permission(f): """Check if the authenticated user belongs to the organization that owns - the schema. Mirrors check_object_edit_permission for schema routes.""" + the schema. + + Unlike check_object_edit_permission and check_collection_edit_permission, + this decorator grants admins unconditional access so that site admins can + manage schemas belonging to any organization. + + The is_authenticated guard is intentionally kept for defensive safety in + case this decorator is ever used without @login_required above it. + """ @wraps(f) def decorated_function(*args, **kwargs): @@ -93,10 +101,13 @@ def decorated_function(*args, **kwargs): if schema is None: return abort(404) + # Admins can manage any schema regardless of organization. if current_user.is_admin: return f(*args, **kwargs) - if schema.org_id not in [org.id for org in current_user.organizations]: + # Schemas with no organization (org_id is None) are not editable by + # regular users — is_organization_member(None) always returns False. + if not current_user.is_organization_member(schema.org_id): return abort(403) return f(*args, **kwargs) diff --git a/mosp/views/schema.py b/mosp/views/schema.py index 18dbb1f..a3f21e3 100644 --- a/mosp/views/schema.py +++ b/mosp/views/schema.py @@ -29,10 +29,10 @@ from mosp.bootstrap import application from mosp.bootstrap import db from mosp.forms import SchemaForm -from mosp.views.decorators import check_schema_edit_permission from mosp.models import Event from mosp.models import JsonObject from mosp.models import Schema +from mosp.views.decorators import check_schema_edit_permission schema_bp = Blueprint("schema_bp", __name__, url_prefix="/schema") schemas_bp = Blueprint("schemas_bp", __name__, url_prefix="/schemas") @@ -318,6 +318,13 @@ def process_form(schema_id=None): flash(gettext("You must specify an organization."), "warning") return redirect(url_for("schema_bp.form")) + # Server-side guard: reject org_id values not belonging to the current user + # (WTForms does not enforce choice membership by default for SelectField). + if not current_user.is_admin and not current_user.is_organization_member( + form.org_id.data + ): + abort(403) + # Edit an existing schema if schema_id is not None: schema = Schema.query.filter(Schema.id == schema_id).first() diff --git a/tests/test_schema.py b/tests/test_schema.py index 56e315e..1ac0de6 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,3 +1,5 @@ +import json + import pytest from werkzeug.security import generate_password_hash @@ -15,12 +17,13 @@ def make_org(session, name): return org -def make_user(session, login, email, org=None): +def make_user(session, login, email, org=None, is_admin=False): user = User( login=login, pwdhash=generate_password_hash("password"), email=email, is_active=True, + is_admin=is_admin, ) if org: user.organizations.append(org) @@ -50,7 +53,7 @@ def login_as(client, login_name): ) -# ── ownership tests ─────────────────────────────────────────────────────────── +# ── GET edit / ownership tests ──────────────────────────────────────────────── def test_non_owner_cannot_edit_schema(client, session): org_a = make_org(session, "OrgA_edit") @@ -74,6 +77,50 @@ def test_owner_can_edit_schema(client, session): assert response.status_code == 200 +def test_edit_nonexistent_schema_returns_404(client, session): + org = make_org(session, "Org_404") + make_user(session, "user_404", "user_404@t.local", org=org) + + login_as(client, "user_404") + response = client.get("/schema/edit/999999") + assert response.status_code == 404 + + +def test_admin_can_edit_any_schema(client, session): + org = make_org(session, "OrgA_admin") + owner = make_user(session, "owner_adm", "owner_adm@t.local", org=org) + schema = make_schema(session, "Schema_adm", org, creator_id=owner.id) + make_user(session, "admin_adm", "admin_adm@t.local", is_admin=True) + + login_as(client, "admin_adm") + response = client.get(f"/schema/edit/{schema.id}") + assert response.status_code == 200 + + +# ── POST edit protection ────────────────────────────────────────────────────── + +def test_non_owner_cannot_post_edit_schema(client, session): + org_a = make_org(session, "OrgA_post") + org_b = make_org(session, "OrgB_post") + owner = make_user(session, "owner_p1", "owner_p1@t.local", org=org_a) + make_user(session, "other_p1", "other_p1@t.local", org=org_b) + schema = make_schema(session, "Schema_p1", org_a, creator_id=owner.id) + + login_as(client, "other_p1") + response = client.post( + f"/schema/edit/{schema.id}", + data={ + "name": "hacked", + "description": "hacked", + "json_schema": json.dumps({"type": "object", "properties": {}}), + "org_id": org_a.id, + }, + ) + assert response.status_code == 403 + + +# ── DELETE ownership tests ──────────────────────────────────────────────────── + def test_non_owner_cannot_delete_schema(client, session): org_a = make_org(session, "OrgA_del") org_b = make_org(session, "OrgB_del") From 232a087b41894f5ab5d70cc43433ca832443cdc4 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 16:19:47 +0200 Subject: [PATCH 06/30] feat(schema): two-step delete with confirmation page (closes #4) GET /schema/delete/ now shows a warning page with the schema name and a count of objects that would be cascade-deleted; POST performs the actual delete and redirects to the schemas list. Both routes are protected by @login_required and @check_schema_edit_permission. A SimpleForm is added to forms.py for CSRF protection on the confirmation form. Tests cover owner GET (200), non-owner GET/POST (403), and successful POST delete (302 + DB removal). --- mosp/forms.py | 6 +++++ mosp/templates/delete_schema.html | 28 +++++++++++++++++++++ mosp/templates/schema.html | 5 ++++ mosp/views/schema.py | 21 +++++++++++++++- tests/test_schema.py | 41 +++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 mosp/templates/delete_schema.html diff --git a/mosp/forms.py b/mosp/forms.py index 6855dda..060fd0b 100644 --- a/mosp/forms.py +++ b/mosp/forms.py @@ -26,6 +26,12 @@ from mosp.models import User +class SimpleForm(FlaskForm): + """A minimal form used solely for CSRF protection (e.g. confirmation pages).""" + + pass + + class RedirectForm(FlaskForm): """Redirect form used for the redirection after the sign in.""" diff --git a/mosp/templates/delete_schema.html b/mosp/templates/delete_schema.html new file mode 100644 index 0000000..f0ac75a --- /dev/null +++ b/mosp/templates/delete_schema.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} +{% block content %} +
+
+
+

{{ _('Delete schema') }}: {{ schema.name }}

+
+
+
+
+
+ +
+
+
+
+
+ {{ form.hidden_tag() }} + + {{ _('Cancel') }} +
+
+
+
+{% endblock %} diff --git a/mosp/templates/schema.html b/mosp/templates/schema.html index 3c9a99a..9c9934b 100644 --- a/mosp/templates/schema.html +++ b/mosp/templates/schema.html @@ -22,6 +22,11 @@

{{ schema.name }}

+ {% if current_user.is_authenticated %} + + + + {% endif %} diff --git a/mosp/views/schema.py b/mosp/views/schema.py index a3f21e3..9b7a334 100644 --- a/mosp/views/schema.py +++ b/mosp/views/schema.py @@ -29,6 +29,7 @@ from mosp.bootstrap import application from mosp.bootstrap import db from mosp.forms import SchemaForm +from mosp.forms import SimpleForm from mosp.models import Event from mosp.models import JsonObject from mosp.models import Schema @@ -372,7 +373,25 @@ def process_form(schema_id=None): @check_schema_edit_permission def delete(schema_id=None): """ - Delete the requested schema. + Show a confirmation page before deleting the schema. + """ + schema = Schema.query.filter(Schema.id == schema_id).first() + object_count = schema.objects.count() + form = SimpleForm() + return render_template( + "delete_schema.html", + schema=schema, + object_count=object_count, + form=form, + ) + + +@schema_bp.route("/delete/", methods=["POST"]) +@login_required +@check_schema_edit_permission +def delete_confirm(schema_id=None): + """ + Perform the actual deletion of the schema after confirmation. """ schema = Schema.query.filter(Schema.id == schema_id).first() db.session.delete(schema) diff --git a/tests/test_schema.py b/tests/test_schema.py index 1ac0de6..2d02930 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -3,6 +3,8 @@ import pytest from werkzeug.security import generate_password_hash +from mosp.bootstrap import db +from mosp.models import JsonObject from mosp.models import Organization from mosp.models import Schema from mosp.models import User @@ -131,3 +133,42 @@ def test_non_owner_cannot_delete_schema(client, session): login_as(client, "other_d1") response = client.get(f"/schema/delete/{schema.id}") assert response.status_code == 403 + + +def test_owner_can_get_delete_warning_page(client, session): + org = make_org(session, "OrgA_del2") + owner = make_user(session, "owner_d2", "owner_d2@t.local", org=org) + schema = make_schema(session, "Schema_d2", org, creator_id=owner.id) + + login_as(client, "owner_d2") + response = client.get(f"/schema/delete/{schema.id}") + assert response.status_code == 200 + assert b"Schema_d2" in response.data + + +def test_non_owner_cannot_post_delete_schema(client, session): + org_a = make_org(session, "OrgA_del3") + org_b = make_org(session, "OrgB_del3") + owner = make_user(session, "owner_d3", "owner_d3@t.local", org=org_a) + make_user(session, "other_d3", "other_d3@t.local", org=org_b) + schema = make_schema(session, "Schema_d3", org_a, creator_id=owner.id) + + login_as(client, "other_d3") + response = client.post(f"/schema/delete/{schema.id}") + assert response.status_code == 403 + # Schema should still exist + assert db.session.get(Schema, schema.id) is not None + + +def test_owner_post_delete_removes_schema(client, session): + org = make_org(session, "OrgA_del4") + owner = make_user(session, "owner_d4", "owner_d4@t.local", org=org) + schema = make_schema(session, "Schema_d4", org, creator_id=owner.id) + schema_id = schema.id + + login_as(client, "owner_d4") + response = client.post(f"/schema/delete/{schema_id}", follow_redirects=False) + assert response.status_code == 302 + assert "/schemas/" in response.headers["Location"] + # Schema must no longer exist in the database + assert db.session.get(Schema, schema_id) is None From b4c375b64acbb40df4edbad79bc2ba6199858fc9 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Wed, 15 Apr 2026 16:23:31 +0200 Subject: [PATCH 07/30] =?UTF-8?q?fix(schema):=20harden=20delete=20flow=20?= =?UTF-8?q?=E2=80=94=20CSRF=20validation,=20404=20guards,=20owner-only=20U?= =?UTF-8?q?I,=20BS5=20spacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mosp/forms.py | 12 ++++++------ mosp/templates/delete_schema.html | 2 +- mosp/templates/schema.html | 2 +- mosp/views/schema.py | 7 +++++++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mosp/forms.py b/mosp/forms.py index 060fd0b..63eee3f 100644 --- a/mosp/forms.py +++ b/mosp/forms.py @@ -26,12 +26,6 @@ from mosp.models import User -class SimpleForm(FlaskForm): - """A minimal form used solely for CSRF protection (e.g. confirmation pages).""" - - pass - - class RedirectForm(FlaskForm): """Redirect form used for the redirection after the sign in.""" @@ -349,3 +343,9 @@ class CollectionForm(FlaskForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + +class SimpleForm(FlaskForm): + """A minimal form used solely for CSRF protection (e.g. confirmation pages).""" + + pass diff --git a/mosp/templates/delete_schema.html b/mosp/templates/delete_schema.html index f0ac75a..d954db2 100644 --- a/mosp/templates/delete_schema.html +++ b/mosp/templates/delete_schema.html @@ -20,7 +20,7 @@

{{ _('Delete schema') }}: {{ schema.name }}

{{ form.hidden_tag() }} - {{ _('Cancel') }} + {{ _('Cancel') }}
diff --git a/mosp/templates/schema.html b/mosp/templates/schema.html index 9c9934b..fee39e2 100644 --- a/mosp/templates/schema.html +++ b/mosp/templates/schema.html @@ -22,7 +22,7 @@

{{ schema.name }}

- {% if current_user.is_authenticated %} + {% if current_user.is_authenticated and (current_user.is_admin or current_user.is_organization_member(schema.org_id)) %} diff --git a/mosp/views/schema.py b/mosp/views/schema.py index 9b7a334..3e156a0 100644 --- a/mosp/views/schema.py +++ b/mosp/views/schema.py @@ -376,6 +376,8 @@ def delete(schema_id=None): Show a confirmation page before deleting the schema. """ schema = Schema.query.filter(Schema.id == schema_id).first() + if schema is None: + abort(404) object_count = schema.objects.count() form = SimpleForm() return render_template( @@ -393,7 +395,12 @@ def delete_confirm(schema_id=None): """ Perform the actual deletion of the schema after confirmation. """ + form = SimpleForm() + if not form.validate_on_submit(): + abort(400) schema = Schema.query.filter(Schema.id == schema_id).first() + if schema is None: + abort(404) db.session.delete(schema) db.session.commit() return redirect(url_for("schemas_bp.list_schemas")) From 23f9316c852a5b3225a277496b9b216605fee989 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 13:43:17 +0200 Subject: [PATCH 08/30] feat(schema): add schema fork with provenance tracking (closes #3) - Add forked_from_id self-referential FK to Schema model - Alembic migration: c3f2a1b4e5d6 - POST /schema/fork/ route: copy schema into user's org, set provenance - Fork modal in schema.html with org selector (Bootstrap 5) - Provenance badge linking back to source schema - Proper 403 error page (replaces redirect-to-login behavior) - 3 new tests: fork creates copy, unauth redirects, wrong org rejects --- ...f2a1b4e5d6_add_forked_from_id_to_schema.py | 35 +++++++++++ mosp/models/schema.py | 4 ++ mosp/templates/errors/403.html | 12 ++++ mosp/templates/schema.html | 43 ++++++++++++++ mosp/views/schema.py | 40 +++++++++++++ mosp/views/views.py | 3 +- tests/test_schema.py | 58 +++++++++++++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/c3f2a1b4e5d6_add_forked_from_id_to_schema.py create mode 100644 mosp/templates/errors/403.html diff --git a/migrations/versions/c3f2a1b4e5d6_add_forked_from_id_to_schema.py b/migrations/versions/c3f2a1b4e5d6_add_forked_from_id_to_schema.py new file mode 100644 index 0000000..29b6e24 --- /dev/null +++ b/migrations/versions/c3f2a1b4e5d6_add_forked_from_id_to_schema.py @@ -0,0 +1,35 @@ +"""add forked_from_id to schema + +Revision ID: c3f2a1b4e5d6 +Revises: 44015f761a68 +Create Date: 2026-04-15 15:30:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "c3f2a1b4e5d6" +down_revision = "44015f761a68" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "schema", + sa.Column("forked_from_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_schema_forked_from_id", + "schema", + "schema", + ["forked_from_id"], + ["id"], + ) + + +def downgrade(): + op.drop_constraint("fk_schema_forked_from_id", "schema", type_="foreignkey") + op.drop_column("schema", "forked_from_id") diff --git a/mosp/models/schema.py b/mosp/models/schema.py index 94a31cc..c9a25b3 100644 --- a/mosp/models/schema.py +++ b/mosp/models/schema.py @@ -33,6 +33,10 @@ class Schema(db.Model): # foreign keys org_id = db.Column(db.Integer(), db.ForeignKey("organization.id"), default=None) creator_id = db.Column(db.Integer(), db.ForeignKey("user.id"), default=True) + forked_from_id = db.Column(db.Integer(), db.ForeignKey("schema.id"), nullable=True, default=None) + + # self-referential relationship for fork provenance + forked_from = db.relationship("Schema", remote_side="Schema.id", foreign_keys=[forked_from_id], lazy="joined") @event.listens_for(Schema, "before_update") diff --git a/mosp/templates/errors/403.html b/mosp/templates/errors/403.html new file mode 100644 index 0000000..b7846bc --- /dev/null +++ b/mosp/templates/errors/403.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block head %} +{{ super() }} +{% endblock %} +{% block content %} +
+
+

Forbidden

+

You do not have permission to access this resource. Go to the home page.

+
+
+{% endblock %} diff --git a/mosp/templates/schema.html b/mosp/templates/schema.html index fee39e2..eff1bc1 100644 --- a/mosp/templates/schema.html +++ b/mosp/templates/schema.html @@ -27,9 +27,41 @@

{{ schema.name }}

{% endif %} + {% if current_user.is_authenticated %} + + {% endif %} + + + + {% if current_user.is_authenticated %} + + {% endif %}
@@ -49,6 +81,17 @@

{{ schema.name }}

+ {% if schema.forked_from %} +
+
+ {{ _('Provenance') }} +
+
+ {{ _('Forked from') }} {{ schema.forked_from.name }} +
+
+ {% endif %} +
diff --git a/mosp/views/schema.py b/mosp/views/schema.py index 3e156a0..d131e88 100644 --- a/mosp/views/schema.py +++ b/mosp/views/schema.py @@ -368,6 +368,46 @@ def process_form(schema_id=None): return redirect(url_for("schema_bp.form", schema_id=new_schema.id)) +@schema_bp.route("/fork/", methods=["POST"]) +@login_required +def fork(schema_id): + """Fork a schema into one of the current user's organizations.""" + source = Schema.query.filter(Schema.id == schema_id).first() + if source is None: + abort(404) + + org_id = request.form.get("org_id", type=int) + if not org_id: + flash(gettext("You must specify an organization."), "warning") + return redirect(url_for("schema_bp.get", schema_id=schema_id)) + + if not current_user.is_admin and not current_user.is_organization_member(org_id): + abort(403) + + base_name = source.name + " (fork)" + name = base_name + suffix = 1 + while Schema.query.filter(Schema.name == name).first() is not None: + suffix += 1 + name = f"{base_name} {suffix}" + + new_schema = Schema( + name=name, + description=source.description, + json_schema=dict(source.json_schema) if source.json_schema else {}, + org_id=org_id, + creator_id=current_user.id, + forked_from_id=schema_id, + ) + db.session.add(new_schema) + db.session.commit() + flash( + gettext("Schema successfully forked as %(name)s.", name=new_schema.name), + "success", + ) + return redirect(url_for("schema_bp.form", schema_id=new_schema.id)) + + @schema_bp.route("/delete/", methods=["GET"]) @login_required @check_schema_edit_permission diff --git a/mosp/views/views.py b/mosp/views/views.py index d66ecf3..7dcd60e 100644 --- a/mosp/views/views.py +++ b/mosp/views/views.py @@ -28,8 +28,7 @@ def authentication_required(error): @current_app.errorhandler(403) def authentication_failed(error): - flash(gettext("Forbidden."), "danger") - return redirect(url_for("login")) + return render_template("errors/403.html"), 403 @current_app.errorhandler(404) diff --git a/tests/test_schema.py b/tests/test_schema.py index 2d02930..f1474ba 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -172,3 +172,61 @@ def test_owner_post_delete_removes_schema(client, session): assert "/schemas/" in response.headers["Location"] # Schema must no longer exist in the database assert db.session.get(Schema, schema_id) is None + + +# ── Fork tests ──────────────────────────────────────────────────────────────── + +def test_fork_creates_new_schema_with_provenance(client, session): + org_src = make_org(session, "OrgSrc_fork") + org_dst = make_org(session, "OrgDst_fork") + creator = make_user(session, "creator_f1", "creator_f1@t.local", org=org_src) + forker = make_user(session, "forker_f1", "forker_f1@t.local", org=org_dst) + source = make_schema(session, "SourceSchema_f1", org_src, creator_id=creator.id) + + login_as(client, "forker_f1") + response = client.post( + f"/schema/fork/{source.id}", + data={"org_id": org_dst.id}, + follow_redirects=False, + ) + assert response.status_code == 302 + + # A new schema should exist with forked_from_id pointing to the source + forked = Schema.query.filter(Schema.forked_from_id == source.id).first() + assert forked is not None + assert forked.org_id == org_dst.id + assert forked.creator_id == forker.id + + +def test_fork_unauthenticated_redirects_to_login(client, session): + org = make_org(session, "OrgAnon_fork") + creator = make_user(session, "creator_f2", "creator_f2@t.local", org=org) + source = make_schema(session, "SourceSchema_f2", org, creator_id=creator.id) + + response = client.post( + f"/schema/fork/{source.id}", + data={"org_id": org.id}, + follow_redirects=False, + ) + # Flask-Login redirects unauthenticated users to the login page + assert response.status_code == 302 + assert "login" in response.headers["Location"].lower() + + +def test_fork_into_non_member_org_is_rejected(client, session): + org_src = make_org(session, "OrgSrc_forkbad") + org_other = make_org(session, "OrgOther_forkbad") + creator = make_user(session, "creator_f3", "creator_f3@t.local", org=org_src) + # forker_f3 belongs to org_src only, not org_other + make_user(session, "forker_f3", "forker_f3@t.local", org=org_src) + source = make_schema(session, "SourceSchema_f3", org_src, creator_id=creator.id) + + login_as(client, "forker_f3") + response = client.post( + f"/schema/fork/{source.id}", + data={"org_id": org_other.id}, + follow_redirects=False, + ) + assert response.status_code == 403 + # No fork should have been created + assert Schema.query.filter(Schema.forked_from_id == source.id).first() is None From a6f8755f40fa94a96ca7437bc47bc35e76e50922 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 13:48:31 +0200 Subject: [PATCH 09/30] chore(deps): update Python and JS dependencies to fix CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python (pip-audit): - flask 3.0.2 → 3.1.3 (CVE-2026-27205) - werkzeug 3.0.1 → 3.1.8 (CVE-2024-34069, CVE-2024-49766/67, CVE-2025-66221, CVE-2026-21860/27199) - flask-cors 3.0.10 → 6.0.2 (PYSEC-2024-71, CVE-2024-1681/6844/6866/6839) - requests 2.31.0 → 2.33.1 (CVE-2024-35195, CVE-2024-47081, CVE-2026-25645) - certifi 2024.2.2 → 2026.2.25 (PYSEC-2024-230) - idna 3.6 → 3.11 (PYSEC-2024-60) JS (npm update — no vulnerabilities, minor bumps): - @fortawesome/fontawesome-free 6.6.0 → 6.7.2 - @json-editor/json-editor 2.15.1 → 2.16.0 - bootstrap 5.3.3 → 5.3.8 - chart.js 4.4.3 → 4.5.1 - codemirror 6.0.1 → 6.0.2 - datatables.net-bs4 2.1.3 → 2.3.7 - papaparse 5.4.1 → 5.5.3 --- package-lock.json | 200 +++++++++++++++++++++++++++------------------- pyproject.toml | 8 +- 2 files changed, 122 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index d57daba..c2ae91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,93 +32,100 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.0.tgz", - "integrity": "sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==", + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" } }, "node_modules/@codemirror/commands": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", - "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "node_modules/@codemirror/language": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz", - "integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==", + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "node_modules/@codemirror/lint": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", - "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/search": { - "version": "6.5.6", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", - "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", - "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } }, "node_modules/@codemirror/view": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.3.tgz", - "integrity": "sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==", + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", "dependencies": { - "@codemirror/state": "^6.4.0", + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz", - "integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" } }, "node_modules/@json-editor/json-editor": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/@json-editor/json-editor/-/json-editor-2.15.1.tgz", - "integrity": "sha512-Z4KFXpL7I9wrKD94c1PcIwFfCvUzYhwM0jH2ihP2OSH8wut0FnqzCfKCL0gdNHG3A2UVsyVJBoU9Iwsc0xB6zQ==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@json-editor/json-editor/-/json-editor-2.16.0.tgz", + "integrity": "sha512-qzCDDImLAkqCpOp3G156ikEHTJKQviVyxie+3QXE1MTxRuT+BZ3yTYsXfUpvgRyaLBcYDJIind59Hj3oz+5TqA==", + "license": "MIT", "dependencies": { "core-js": "^3.27.2" }, @@ -127,35 +134,46 @@ } }, "node_modules/@kurkle/color": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", - "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" }, "node_modules/@lezer/common": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", - "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" }, "node_modules/@lezer/highlight": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", - "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/lr": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", - "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", "peer": true, "funding": { "type": "opencollective", @@ -163,9 +181,9 @@ } }, "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", "funding": [ { "type": "github", @@ -176,6 +194,7 @@ "url": "https://opencollective.com/bootstrap" } ], + "license": "MIT", "peerDependencies": { "@popperjs/core": "^2.11.8" } @@ -184,15 +203,17 @@ "version": "1.13.18", "resolved": "https://registry.npmjs.org/bootstrap-select/-/bootstrap-select-1.13.18.tgz", "integrity": "sha512-V1IzK4rxBq5FrJtkzSH6RmFLFBsjx50byFbfAf8jYyXROWs7ZpprGjdHeoyq2HSsHyjJhMMwjsQhRoYAfxCGow==", + "license": "MIT", "peerDependencies": { "bootstrap": ">=3.0.0", "jquery": "1.9.1 - 3" } }, "node_modules/chart.js": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz", - "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -204,6 +225,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", "peerDependencies": { "chart.js": ">=3.0.0" } @@ -211,12 +233,14 @@ "node_modules/code-prettify": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/code-prettify/-/code-prettify-0.1.0.tgz", - "integrity": "sha512-tNOWwXoF3ycqtvLCGPLYds2hKekmZfsPWinbRcLk6BBHBaSf+v+HJOvfg33VfVzYzvQ6zmVq+WO88oKweiJSQg==" + "integrity": "sha512-tNOWwXoF3ycqtvLCGPLYds2hKekmZfsPWinbRcLk6BBHBaSf+v+HJOvfg33VfVzYzvQ6zmVq+WO88oKweiJSQg==", + "license": "Apache-2.0" }, "node_modules/codemirror": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", - "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -228,10 +252,11 @@ } }, "node_modules/core-js": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", - "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "hasInstallScript": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -240,75 +265,86 @@ "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" }, "node_modules/datatables": { "version": "1.10.18", "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz", "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==", + "license": "MIT", "dependencies": { "jquery": ">=1.7" } }, "node_modules/datatables.net": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.1.3.tgz", - "integrity": "sha512-emlF55eF4fhzOUuE+0dEFZCDbuhXqjvdZagW0bLXaU4nDesCWrjYXj0NdtHTKLhVYyfZhTlA8mEDL2Wa64U0ig==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-2.3.7.tgz", + "integrity": "sha512-AvsjG/Nkp6OxeyBKYZauemuzQCPogE1kOtKwG4sYjvdqGCSLiGaJagQwXv4YxG+ts5vaJr6qKGG9ec3g6vTo3w==", + "license": "MIT", "dependencies": { "jquery": ">=1.7" } }, "node_modules/datatables.net-bs4": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-2.1.3.tgz", - "integrity": "sha512-kt7fRDFXbDjCJAdILyQNmIIeqz3nrlu4AJMCja75YB/MbNobKvdxMV7D9PY2oe7h1EifdUmwahzVKCfh7Unvaw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-2.3.7.tgz", + "integrity": "sha512-ZyofK/3Unj0lQdAIsI9gT5oGCwTnx8y2UF6muYkbdO/Rw9h+x2kQwFkuCX5J0j6N2B+YCwzHk2OPu9yU8nk1Kw==", + "license": "MIT", "dependencies": { - "datatables.net": "2.1.3", + "datatables.net": "2.3.7", "jquery": ">=1.7" } }, "node_modules/jquery": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", "engines": { "node": "*" } }, "node_modules/papaparse": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", - "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, "node_modules/style-mod": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", - "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/pyproject.toml b/pyproject.toml index 070c582..043e7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,8 @@ license = "AGPL-3.0-or-later" [tool.poetry.dependencies] python = ">=3.8,<4.0" SQLAlchemy = "^2.0.23" -Flask = "^3.0.0" -werkzeug = "3.0.6" +Flask = "^3.1.3" +werkzeug = "^3.1.6" Flask-SQLAlchemy = "^3.0.3" Flask-Login = "^0.6.0" Flask-Principal = "^0.4.0" @@ -22,10 +22,10 @@ WTForms = "^3.0.1" validate_email = "^1.3" flask-babel = "^4.0.0" alembic = "^1.7.4" -requests = "^2.32.4" +requests = "^2.33.0" jsonschema = "^4.1.1" psycopg2-binary = "^2.9.9" -flask-cors = "^4.0.2" +flask-cors = "^6.0.0" networkx = "^2.4" email_validator = "^1.1.1" flask_restx = "^1.3.0" From 15c47265285641c0ff686725097d4e70b8f3c3be Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 13:49:22 +0200 Subject: [PATCH 10/30] chore(release): bump version to 0.18.0 and update CHANGELOG --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39288e3..7323060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ Changelog ========= +v0.18.0 (2026-04-16) +--------------------- + +Security +~~~~~~~~ +- Enforce SECRET_KEY and SECURITY_PASSWORD_SALT from environment variables; + raise RuntimeError on startup if a known-weak value is detected (closes #79). +- Add ownership authorization to schema edit and delete routes (IDOR fix); + users may only modify schemas belonging to their organization. +- Validate org_id server-side on schema creation to prevent privilege escalation. +- Upgrade Flask (3.1.3), Werkzeug (3.1.8), flask-cors (6.0.2), requests (2.33.1), + certifi, and idna to fix multiple published CVEs. + +New +~~~ +- Schema delete is now a two-step flow: a GET confirmation page showing the + cascade-delete count, followed by a POST to perform the deletion (closes #4). +- Schema fork: any authenticated user can copy a public schema into their own + organization via a modal dialog; forked schemas display a provenance badge + linking back to the source (closes #3). +- Proper HTTP 403 error page replacing the previous redirect-to-login behavior. + +Changes +~~~~~~~ +- Use postgresql:// scheme in all config files (SQLAlchemy 2.x compatibility). +- Update JS dependencies: bootstrap 5.3.8, chart.js 4.5.1, codemirror 6.0.2, + datatables.net-bs4 2.3.7, @json-editor/json-editor 2.16.0, papaparse 5.5.3, + @fortawesome/fontawesome-free 6.7.2. + + v0.17.1 (2021-10-28) -------------------- diff --git a/package.json b/package.json index 7c7233d..1a32e6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mosp", - "version": "0.17.0", + "version": "0.18.0", "description": "A platform for creating, editing and sharing JSON objects.", "private": true, "scripts": { diff --git a/pyproject.toml b/pyproject.toml index 043e7ab..74bba41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mosp" -version = "0.17.1" +version = "0.18.0" description = "A platform for creating, editing and sharing JSON objects." authors = ["Cédric Bonhomme "] license = "AGPL-3.0-or-later" From 099fd3e2b04bf510e1beed37a8a05492ee27425f Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 14:36:56 +0200 Subject: [PATCH 11/30] fix: address post-review findings before merge - docker-compose.yml: remove empty SECRET_KEY/SECURITY_PASSWORD_SALT placeholders that caused docker-compose up to crash; replace with comments only - mosp/models/schema.py: wrap forked_from_id column and relationship definitions to respect 100-char line limit (flake8 E501) - mosp/templates/errors/403.html: replace Bootstrap 3 'well' class with Bootstrap 5 'alert alert-danger' - mosp/templates/schema.html: apply same org-membership guard to edit button as already applied to delete button (cosmetic hardening) - tests/test_schema.py: add test verifying forged org_id is rejected on schema creation; correct assertion to 302 since WTForms choice validation fires first - .gitignore: exclude instance/test.py and uv.lock --- .gitignore | 6 ++++++ docker-compose.yml | 8 +++++--- mosp/models/schema.py | 8 ++++++-- mosp/templates/errors/403.html | 2 +- mosp/templates/schema.html | 2 ++ tests/test_schema.py | 27 +++++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 6df8e27..ea743c6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,12 @@ dist/ # Claude Code CLAUDE.md +# Package manager artifacts (not used by this project) +uv.lock + +# Local instance configs (not for version control) +instance/test.py + # Project related var/log/ docs/_build/ diff --git a/docker-compose.yml b/docker-compose.yml index 8f0984c..44b568f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,10 +22,12 @@ services: - MOSP_CONFIG=docker.py - HOST=0.0.0.0 - PORT=5000 - # Required: set strong random values before deploying + # Required: set SECRET_KEY and SECURITY_PASSWORD_SALT via a .env file + # or by passing them as environment variables before running docker-compose. # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - SECRET_KEY= - - SECURITY_PASSWORD_SALT= + # Example .env file: + # SECRET_KEY= + # SECURITY_PASSWORD_SALT= command: "./entrypoint.sh" volumes: - .:/mosp:rw diff --git a/mosp/models/schema.py b/mosp/models/schema.py index c9a25b3..de4c74a 100644 --- a/mosp/models/schema.py +++ b/mosp/models/schema.py @@ -33,10 +33,14 @@ class Schema(db.Model): # foreign keys org_id = db.Column(db.Integer(), db.ForeignKey("organization.id"), default=None) creator_id = db.Column(db.Integer(), db.ForeignKey("user.id"), default=True) - forked_from_id = db.Column(db.Integer(), db.ForeignKey("schema.id"), nullable=True, default=None) + forked_from_id = db.Column( + db.Integer(), db.ForeignKey("schema.id"), nullable=True, default=None + ) # self-referential relationship for fork provenance - forked_from = db.relationship("Schema", remote_side="Schema.id", foreign_keys=[forked_from_id], lazy="joined") + forked_from = db.relationship( + "Schema", remote_side="Schema.id", foreign_keys=[forked_from_id], lazy="joined" + ) @event.listens_for(Schema, "before_update") diff --git a/mosp/templates/errors/403.html b/mosp/templates/errors/403.html index b7846bc..c2292e2 100644 --- a/mosp/templates/errors/403.html +++ b/mosp/templates/errors/403.html @@ -4,7 +4,7 @@ {% endblock %} {% block content %}
-
+

Forbidden

You do not have permission to access this resource. Go to the home page.

diff --git a/mosp/templates/schema.html b/mosp/templates/schema.html index eff1bc1..bf950bd 100644 --- a/mosp/templates/schema.html +++ b/mosp/templates/schema.html @@ -19,9 +19,11 @@

{{ schema.name }}

+ {% if current_user.is_authenticated and (current_user.is_admin or current_user.is_organization_member(schema.org_id)) %} + {% endif %} {% if current_user.is_authenticated and (current_user.is_admin or current_user.is_organization_member(schema.org_id)) %} diff --git a/tests/test_schema.py b/tests/test_schema.py index f1474ba..77f4674 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -121,6 +121,33 @@ def test_non_owner_cannot_post_edit_schema(client, session): assert response.status_code == 403 +def test_create_schema_with_forged_org_id_is_rejected(client, session): + """A user cannot create a schema claiming ownership of an org they don't belong to. + + WTForms SelectField validates that the submitted org_id is among the declared + choices (which are built from current_user.organizations), so a forged org_id + fails form validation and results in a redirect. The important property is that + no schema is created for the target organization. + """ + org_a = make_org(session, "OrgA_forge") + org_b = make_org(session, "OrgB_forge") + make_user(session, "user_forge", "user_forge@t.local", org=org_a) + + login_as(client, "user_forge") + response = client.post( + "/schema/create", + data={ + "name": "ForgedSchema", + "description": "attempt to own org_b", + "json_schema": json.dumps({"type": "object", "properties": {}}), + "org_id": org_b.id, # org_b — user does not belong here + }, + ) + # Form validation rejects the forged org_id → redirect (not created) + assert response.status_code == 302 + assert Schema.query.filter(Schema.org_id == org_b.id).first() is None + + # ── DELETE ownership tests ──────────────────────────────────────────────────── def test_non_owner_cannot_delete_schema(client, session): From b2b476f75466a7c9d2e10f4dd89cdb7f3063230e Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 15:06:40 +0200 Subject: [PATCH 12/30] fix: replace deprecated datetime.utcnow() and API debug prints Replace datetime.utcnow() (deprecated in Python 3.12) with datetime.now(timezone.utc).replace(tzinfo=None) across all models and views. Replace print(e) debug calls in api/v2 with proper logger.error() calls using exc_info=True. --- mosp/api/v2/collection.py | 5 ++++- mosp/api/v2/object.py | 2 +- mosp/api/v2/organization.py | 5 ++++- mosp/api/v2/schema.py | 5 ++++- mosp/api/v2/user.py | 6 ++++-- mosp/api/v2/version.py | 5 ++++- mosp/models/collection.py | 9 +++++++-- mosp/models/event.py | 5 ++++- mosp/models/jsonobject.py | 7 +++++-- mosp/models/license.py | 5 ++++- mosp/models/organization.py | 5 ++++- mosp/models/schema.py | 5 +++-- mosp/models/user.py | 9 +++++++-- mosp/models/version.py | 5 ++++- mosp/views/admin.py | 3 ++- mosp/views/objectbp.py | 3 ++- mosp/views/session_mgmt.py | 3 ++- mosp/views/stats.py | 4 ++-- 18 files changed, 67 insertions(+), 24 deletions(-) diff --git a/mosp/api/v2/collection.py b/mosp/api/v2/collection.py index 73b8535..6728e40 100644 --- a/mosp/api/v2/collection.py +++ b/mosp/api/v2/collection.py @@ -1,4 +1,6 @@ #! /usr/bin/env python +import logging + from flask_restx import fields from flask_restx import Namespace from flask_restx import reqparse @@ -9,6 +11,7 @@ from mosp.api.v2.types import ResultType from mosp.models import Collection +logger = logging.getLogger(__name__) collection_ns = Namespace("collection", description="collection related operations") @@ -76,7 +79,7 @@ def get(self): results = query.offset(offset * limit) count = total except Exception as e: - print(e) + logger.error(str(e), exc_info=True) result["data"] = results result["metadata"]["count"] = count diff --git a/mosp/api/v2/object.py b/mosp/api/v2/object.py index d7fc6ee..57d31cf 100644 --- a/mosp/api/v2/object.py +++ b/mosp/api/v2/object.py @@ -209,7 +209,7 @@ def post(self): sqlalchemy.exc.InvalidRequestError, ) as e: logger.error("Error when creating object {}".format(object["id"])) - print(e) + logger.error(str(e), exc_info=True) # errors.append(object["id"]) db.session.rollback() diff --git a/mosp/api/v2/organization.py b/mosp/api/v2/organization.py index 1491a31..c55390c 100644 --- a/mosp/api/v2/organization.py +++ b/mosp/api/v2/organization.py @@ -1,4 +1,6 @@ #! /usr/bin/env python +import logging + from flask_restx import fields from flask_restx import inputs from flask_restx import Namespace @@ -10,6 +12,7 @@ from mosp.api.v2.types import ResultType from mosp.models import Organization +logger = logging.getLogger(__name__) organization_ns = Namespace( "organization", description="organization related operations" @@ -79,7 +82,7 @@ def get(self): results = query.offset(offset * limit) count = total except Exception as e: - print(e) + logger.error(str(e), exc_info=True) result["data"] = results result["metadata"]["count"] = count diff --git a/mosp/api/v2/schema.py b/mosp/api/v2/schema.py index 9ef3c30..a96dddf 100644 --- a/mosp/api/v2/schema.py +++ b/mosp/api/v2/schema.py @@ -1,4 +1,6 @@ #! /usr/bin/env python +import logging + from flask_restx import fields from flask_restx import Namespace from flask_restx import reqparse @@ -9,6 +11,7 @@ from mosp.api.v2.types import ResultType from mosp.models import Schema +logger = logging.getLogger(__name__) schema_ns = Namespace("schema", description="schema related operations") @@ -86,7 +89,7 @@ def get(self): results = query.offset(offset * limit) count = total except Exception as e: - print(e) + logger.error(str(e), exc_info=True) result["data"] = results result["metadata"]["count"] = count diff --git a/mosp/api/v2/user.py b/mosp/api/v2/user.py index e2e9829..b638f7d 100644 --- a/mosp/api/v2/user.py +++ b/mosp/api/v2/user.py @@ -1,4 +1,5 @@ #! /usr/bin/env python +import logging import secrets import sqlalchemy @@ -22,6 +23,7 @@ from mosp.models import User from mosp.notifications import notifications +logger = logging.getLogger(__name__) user_ns = Namespace("user", description="user related operations") @@ -115,7 +117,7 @@ def get(self): results = query.offset(offset * limit) count = total except Exception as e: - print(e) + logger.error(str(e), exc_info=True) result["data"] = results result["metadata"]["count"] = count @@ -169,7 +171,7 @@ def post(self): try: notifications.confirm_account(new_user) except Exception as e: - print(e) + logger.error(str(e), exc_info=True) # marshalling will skip none values and we do not want to return the API key new_user.apikey = None diff --git a/mosp/api/v2/version.py b/mosp/api/v2/version.py index a0cb810..603afe1 100644 --- a/mosp/api/v2/version.py +++ b/mosp/api/v2/version.py @@ -1,4 +1,6 @@ #! /usr/bin/env python +import logging + from flask_restx import fields from flask_restx import Namespace from flask_restx import reqparse @@ -9,6 +11,7 @@ from mosp.api.v2.types import ResultType from mosp.models import Version +logger = logging.getLogger(__name__) version_ns = Namespace("version", description="version related operations") @@ -72,7 +75,7 @@ def get(self): results = query.offset(offset * limit) count = total except Exception as e: - print(e) + logger.error(str(e), exc_info=True) result["data"] = results result["metadata"]["count"] = count diff --git a/mosp/models/collection.py b/mosp/models/collection.py index 5d4407a..52d9a25 100644 --- a/mosp/models/collection.py +++ b/mosp/models/collection.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime +from datetime import timezone from sqlalchemy.dialects.postgresql import UUID @@ -18,8 +19,12 @@ class Collection(db.Model): ) name = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.String(500), default="") - date_created = db.Column(db.DateTime(), default=datetime.utcnow) - last_updated = db.Column(db.DateTime(), default=datetime.utcnow) + date_created = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + last_updated = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) # foreign keys creator_id = db.Column(db.Integer(), db.ForeignKey("user.id"), nullable=False) diff --git a/mosp/models/event.py b/mosp/models/event.py index 66841f6..f0ed5e5 100644 --- a/mosp/models/event.py +++ b/mosp/models/event.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from sqlalchemy.orm import validates @@ -13,7 +14,9 @@ class Event(db.Model): action = db.Column(db.String(), nullable=False) subject = db.Column(db.String(), nullable=False) initiator = db.Column(db.String()) - date = db.Column(db.DateTime(), default=datetime.utcnow) + date = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) @validates("initiator") def validates_initiator(self, key: str, value: str): diff --git a/mosp/models/jsonobject.py b/mosp/models/jsonobject.py index 3b0cf49..ade91db 100644 --- a/mosp/models/jsonobject.py +++ b/mosp/models/jsonobject.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from typing import Union import jsonschema @@ -41,7 +42,9 @@ class JsonObject(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.Text(), nullable=False) description = db.Column(db.Text(), nullable=False) - last_updated = db.Column(db.DateTime(), default=datetime.utcnow) + last_updated = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) json_object = db.Column(JSONB, default={}) is_locked = db.Column(db.Boolean(), default=False) @@ -130,4 +133,4 @@ def update_modified_on_update_listener(mapper, connection, target): """Event listener that runs before a record is updated, and sets the last_updated field accordingly. """ - target.last_updated = datetime.utcnow() + target.last_updated = datetime.now(timezone.utc).replace(tzinfo=None) diff --git a/mosp/models/license.py b/mosp/models/license.py index ec193ee..53b50ea 100644 --- a/mosp/models/license.py +++ b/mosp/models/license.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from mosp.bootstrap import db @@ -11,7 +12,9 @@ class License(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(), default="", nullable=False, unique=True) license_id = db.Column(db.String(), default="", nullable=False, unique=True) - created_at = db.Column(db.DateTime(), default=datetime.utcnow) + created_at = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) def __str__(self): return self.name diff --git a/mosp/models/organization.py b/mosp/models/organization.py index b24c7e5..9e901ac 100644 --- a/mosp/models/organization.py +++ b/mosp/models/organization.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from mosp.bootstrap import db @@ -11,7 +12,9 @@ class Organization(db.Model): description = db.Column(db.String(500), default="") organization_type = db.Column(db.String(100), default="") website = db.Column(db.String(100), default="") - last_updated = db.Column(db.DateTime(), default=datetime.utcnow) + last_updated = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) is_membership_restricted = db.Column(db.Boolean(), default=True) diff --git a/mosp/models/schema.py b/mosp/models/schema.py index de4c74a..cc29a15 100644 --- a/mosp/models/schema.py +++ b/mosp/models/schema.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from sqlalchemy import event from sqlalchemy.dialects.postgresql import JSONB @@ -19,7 +20,7 @@ class Schema(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100)) description = db.Column(db.String(500)) - last_updated = db.Column(db.DateTime(), default=datetime.utcnow) + last_updated = db.Column(db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) json_schema = db.Column(JSONB, default={}) # relationship @@ -48,4 +49,4 @@ def update_modified_on_update_listener(mapper, connection, target): """Event listener that runs before a record is updated, and sets the last_updated field accordingly. """ - target.last_updated = datetime.utcnow() + target.last_updated = datetime.now(timezone.utc).replace(tzinfo=None) diff --git a/mosp/models/user.py b/mosp/models/user.py index 6e3c327..b45d621 100644 --- a/mosp/models/user.py +++ b/mosp/models/user.py @@ -1,6 +1,7 @@ import re import secrets from datetime import datetime +from datetime import timezone from flask_login import UserMixin from sqlalchemy.orm import validates @@ -31,8 +32,12 @@ class User(db.Model, UserMixin): login = db.Column(db.String(30), unique=True, nullable=False) pwdhash = db.Column(db.String(), nullable=False) email = db.Column(db.String(256), nullable=False) - created_at = db.Column(db.DateTime(), default=datetime.utcnow) - last_seen = db.Column(db.DateTime(), default=datetime.utcnow) + created_at = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + last_seen = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) apikey = db.Column(db.String(100), default=generate_token, unique=True) # user rights diff --git a/mosp/models/version.py b/mosp/models/version.py index 5ba0e01..3de7c00 100644 --- a/mosp/models/version.py +++ b/mosp/models/version.py @@ -1,4 +1,5 @@ from datetime import datetime +from datetime import timezone from sqlalchemy.dialects.postgresql import JSONB @@ -13,7 +14,9 @@ class Version(db.Model): name = db.Column(db.Text(), nullable=False) description = db.Column(db.Text(), nullable=False) - last_updated = db.Column(db.DateTime(), default=datetime.utcnow) + last_updated = db.Column( + db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) json_object = db.Column(JSONB, default={}) # relationships diff --git a/mosp/views/admin.py b/mosp/views/admin.py index e81d862..5478c7f 100644 --- a/mosp/views/admin.py +++ b/mosp/views/admin.py @@ -1,6 +1,7 @@ import logging from datetime import datetime from datetime import timedelta +from datetime import timezone from flask import Blueprint from flask import current_app @@ -40,7 +41,7 @@ @login_required @admin_permission.require(http_exception=403) def dashboard(): - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) on_week_ago = now - timedelta(weeks=1) four_weeks_ago = now - timedelta(weeks=4) active_users = User.query.filter(User.last_seen >= on_week_ago) diff --git a/mosp/views/objectbp.py b/mosp/views/objectbp.py index ac0d88b..41c0d19 100644 --- a/mosp/views/objectbp.py +++ b/mosp/views/objectbp.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from datetime import timezone from typing import Dict from typing import Union @@ -467,7 +468,7 @@ def copy(object_id=None): new_object.description = json_object.description new_object.json_object = json_object.json_object new_object.refers_to.append(json_object) - new_object.last_updated = datetime.utcnow() + new_object.last_updated = datetime.now(timezone.utc).replace(tzinfo=None) db.session.add(new_object) db.session.commit() diff --git a/mosp/views/session_mgmt.py b/mosp/views/session_mgmt.py index 48df8f0..b1a9373 100644 --- a/mosp/views/session_mgmt.py +++ b/mosp/views/session_mgmt.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from datetime import timezone import sqlalchemy from flask import current_app @@ -65,7 +66,7 @@ def load_user(user_id): @current_app.before_request def before_request(): if current_user.is_authenticated: - current_user.last_seen = datetime.utcnow() + current_user.last_seen = datetime.now(timezone.utc).replace(tzinfo=None) db.session.commit() diff --git a/mosp/views/stats.py b/mosp/views/stats.py index 10308eb..815b702 100644 --- a/mosp/views/stats.py +++ b/mosp/views/stats.py @@ -65,7 +65,7 @@ def digraph(software=None): @stats_bp.route("/objects/most-viewed.json", methods=["GET"]) def most_viewed_objects(): nb_weeks = request.args.get("nb_weeks", default=32, type=int) - nb_weeks_ago = datetime.datetime.utcnow() - datetime.timedelta(weeks=nb_weeks) + nb_weeks_ago = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(weeks=nb_weeks) events = Event.query.filter( Event.scope == "JsonObject", Event.action == "object_bp.view:GET", @@ -112,7 +112,7 @@ def most_viewed_schemas(): # look for uuid of schemas in JsonObject scope nb_weeks = request.args.get("nb_weeks", default=32, type=int) - nb_weeks_ago = datetime.datetime.utcnow() - datetime.timedelta(weeks=nb_weeks) + nb_weeks_ago = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(weeks=nb_weeks) events = Event.query.filter( Event.scope == "JsonObject", Event.action == "apiv2.object_objects_list:GET", From 4d24194263b771181558d32099eeeb3c180c2b84 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 15:06:53 +0200 Subject: [PATCH 13/30] chore(release): update CHANGELOG for v0.18.0 final items --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7323060..927c3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ New Changes ~~~~~~~ +- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) across + all models and views (Python 3.12 compatibility). +- Replace print() debug calls in api/v2 with proper structured logging. - Use postgresql:// scheme in all config files (SQLAlchemy 2.x compatibility). - Update JS dependencies: bootstrap 5.3.8, chart.js 4.5.1, codemirror 6.0.2, datatables.net-bs4 2.3.7, @json-editor/json-editor 2.16.0, papaparse 5.5.3, From 4b19f8525b5cbe7771d2762285ee5229f838759b Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 15:07:47 +0200 Subject: [PATCH 14/30] fix: replace stray print(e) with logger.error in objectbp.py --- mosp/views/objectbp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mosp/views/objectbp.py b/mosp/views/objectbp.py index 41c0d19..009fc39 100644 --- a/mosp/views/objectbp.py +++ b/mosp/views/objectbp.py @@ -1,4 +1,5 @@ import json +import logging from datetime import datetime from datetime import timezone from typing import Dict @@ -27,6 +28,8 @@ from mosp.models import Version from mosp.views.decorators import check_object_edit_permission +logger = logging.getLogger(__name__) + object_bp = Blueprint("object_bp", __name__, url_prefix="/object") objects_bp = Blueprint("objects_bp", __name__, url_prefix="/objects") @@ -362,7 +365,7 @@ def process_form(object_id=None): db.session.add(new_event) db.session.commit() except Exception as e: - print(e) + logger.error(str(e), exc_info=True) form.name.errors.append("Name already exists.") return redirect(url_for("object_bp.form", object_id=json_object.id)) From ef38aca7c097a77182a1ddecee01d652c56fea63 Mon Sep 17 00:00:00 2001 From: Philippe Parage Date: Thu, 16 Apr 2026 15:21:38 +0200 Subject: [PATCH 15/30] =?UTF-8?q?fix(ui):=20Bootstrap=204=E2=86=925=20migr?= =?UTF-8?q?ation=20=E2=80=94=20data-bs-*=20attributes=20and=20btn-close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace data-toggle/data-dismiss/data-target with data-bs-* equivalents across all templates. Replace Bootstrap 4 .close buttons with Bootstrap 5 .btn-close. Downgrade codemirror from v6 (ES-module-only) to v5.65.x which matches the lib/codemirror.js path used in edit_schema.html. --- mosp/templates/collection.html | 2 +- mosp/templates/edit_json.html | 8 +- mosp/templates/layout.html | 8 +- mosp/templates/list_versions.html | 8 +- mosp/templates/organization.html | 12 +-- mosp/templates/view_object.html | 18 ++-- mosp/templates/view_version.html | 6 +- package-lock.json | 152 ++---------------------------- package.json | 2 +- 9 files changed, 34 insertions(+), 182 deletions(-) diff --git a/mosp/templates/collection.html b/mosp/templates/collection.html index 3d9f29c..c28fb51 100644 --- a/mosp/templates/collection.html +++ b/mosp/templates/collection.html @@ -14,7 +14,7 @@

{{ collection.name }}

-