diff --git a/README.md b/README.md index 600a5d5..fcd4626 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ To change the password: flask --app codehelp setpassword [username] ``` +4. (Optional) To serve files from `/.well-known` (for domain verification, + etc.), place the files in a `.well-known` directory inside the Flask + instance folder. + Running an Application ---------------------- diff --git a/src/codehelp/.well-known/microsoft-identity-association.json b/src/codehelp/.well-known/microsoft-identity-association.json deleted file mode 100644 index bdf9e9f..0000000 --- a/src/codehelp/.well-known/microsoft-identity-association.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "associatedApplications": [ - { - "applicationId": "09a5f3e6-3de5-45cb-9889-256c296fe85a" - } - ] -} diff --git a/src/codehelp/__init__.py b/src/codehelp/__init__.py index 7a84680..99f7f52 100644 --- a/src/codehelp/__init__.py +++ b/src/codehelp/__init__.py @@ -5,9 +5,7 @@ from pathlib import Path from typing import Any -from flask import send_from_directory from flask.app import Flask -from flask.wrappers import Response from gened import base @@ -58,11 +56,6 @@ def create_app(test_config: dict[str, Any] | None = None, instance_path: Path | # register app-specific charts in the admin interface admin.register_with_gened() - # make a simple route for the .well-known directory - @app.route('/.well-known/') - def well_known(path: Path) -> Response: - return send_from_directory('.well-known', path) - # add navbar items app.config['NAVBAR_ITEM_TEMPLATES'].append("tutor_nav_item.html") diff --git a/src/gened/base.py b/src/gened/base.py index 9d31ca0..2c936d8 100644 --- a/src/gened/base.py +++ b/src/gened/base.py @@ -11,7 +11,8 @@ import flask.app from dotenv import load_dotenv -from flask import Flask, render_template +from flask import Flask, render_template, send_from_directory +from flask.wrappers import Response from werkzeug.middleware.proxy_fix import ProxyFix from . import ( @@ -207,4 +208,8 @@ def inject_auth_data() -> dict[str, Any]: def landing() -> str: return render_template("landing.html") + @app.route('/.well-known/') + def well_known(path: Path) -> Response: + return send_from_directory(Path(app.instance_path) / '.well-known', path) + return app diff --git a/tests/conftest.py b/tests/conftest.py index 6ef5471..0bf0af1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,14 +2,14 @@ # # SPDX-License-Identifier: AGPL-3.0-only -import os import tempfile from pathlib import Path -import codehelp import openai import pytest from dotenv import find_dotenv, load_dotenv + +import codehelp from gened.admin import reload_consumers from gened.db import get_db, init_db from gened.testing.mocks import mock_async_completion, mock_completion @@ -26,7 +26,7 @@ def _load_env(): load_dotenv(env_file) -@pytest.fixture() +@pytest.fixture def app(monkeypatch, request): """ Provides an application object and by default monkey patches openai to *not* send requests: the most common case for testing. @@ -40,35 +40,37 @@ def app(monkeypatch, request): monkeypatch.setattr(openai.resources.chat.Completions, "create", mock_completion(0.0)) monkeypatch.setattr(openai.resources.chat.AsyncCompletions, "create", mock_async_completion(0.0)) - # Create an app and initialize the DB - db_fd, db_path = tempfile.mkstemp() + # Create a temporary app root with instance directory + with tempfile.TemporaryDirectory() as temp_dir: + instance_path = Path(temp_dir) - app = codehelp.create_app( - test_config={ - 'TESTING': True, - 'DATABASE': db_path, - 'OPENAI_API_KEY': 'invalid', # ensure an invalid API key for testing - }, - instance_path=Path(db_path).absolute().parent, - ) + # Create database in the instance directory + db_path = instance_path / 'test.db' - with app.app_context(): - init_db() - get_db().executescript(_test_data_sql) - reload_consumers() # reload consumers from now-initialized DB + app = codehelp.create_app( + test_config={ + 'TESTING': True, + 'DATABASE': str(db_path), + 'OPENAI_API_KEY': 'invalid', # ensure an invalid API key for testing + }, + instance_path=instance_path, + ) - yield app + with app.app_context(): + init_db() + get_db().executescript(_test_data_sql) + reload_consumers() # reload consumers from now-initialized DB - os.close(db_fd) - os.unlink(db_path) + yield app + # Directory cleanup happens automatically when the context manager exits -@pytest.fixture() +@pytest.fixture def client(app): return app.test_client() -@pytest.fixture() +@pytest.fixture def runner(app): return app.test_cli_runner() @@ -87,6 +89,6 @@ def logout(self): return self._client.post('/auth/logout') -@pytest.fixture() +@pytest.fixture def auth(client): return AuthActions(client) diff --git a/tests/test_well_known.py b/tests/test_well_known.py new file mode 100644 index 0000000..f4e821b --- /dev/null +++ b/tests/test_well_known.py @@ -0,0 +1,53 @@ +from pathlib import Path + + +def test_well_known_file(client, app): + """Test serving an existing file from .well-known""" + # Create a test file in the instance/.well-known directory + well_known_dir = Path(app.instance_path) / '.well-known' + well_known_dir.mkdir(exist_ok=True) + test_file = well_known_dir / 'test.txt' + test_file.write_text('test content') + + # Request the file + response = client.get('/.well-known/test.txt') + + assert response.status_code == 200 + assert response.data == b'test content' + + +def test_well_known_missing(client): + """Test requesting a non-existent file from .well-known""" + response = client.get('/.well-known/nonexistent.txt') + assert response.status_code == 404 + + +def test_well_known_subdir(client, app): + """Test serving a file from a subdirectory in .well-known""" + # Create a test file in a subdirectory + well_known_dir = Path(app.instance_path) / '.well-known' + subdir = well_known_dir / 'subdir' + subdir.mkdir(parents=True, exist_ok=True) + test_file = subdir / 'test.txt' + test_file.write_text('subdir test content') + + # Request the file + response = client.get('/.well-known/subdir/test.txt') + + assert response.status_code == 200 + assert response.data == b'subdir test content' + + +def test_well_known_traversal(client, app): + """Test that path traversal attempts are blocked""" + # Create a file in the instance directory (parent of .well-known) + secret_file = Path(app.instance_path) / 'secret.txt' + secret_file.write_text('secret content') + + # Attempt to access the file through path traversal + response = client.get('/.well-known/../secret.txt') + assert response.status_code == 404 + + # Verify the file exists and is readable directly from disk + assert secret_file.exists() + assert secret_file.read_text() == 'secret content'