diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py index 3b669195..eb88adef 100644 --- a/src/routes/main_routes.py +++ b/src/routes/main_routes.py @@ -939,7 +939,9 @@ def create_path(path_id): Request headers: X-Learning-Path-Token (required) - the secret token chosen by the - client (should be a random UUID or similar). + client. Must be at least 16 characters + (a random UUID or similar high-entropy value). + Tokens shorter than 16 characters are rejected. Request body (JSON): Any JSON object representing the initial learning-path state. @@ -981,10 +983,11 @@ def read_path(path_id): Request headers: X-Learning-Path-Token (required) - the token associated with this - path when it was created. + path when it was created. Must be at least + 16 characters long. Response 200: {"path_id": "", "data": { ... }} - Response 400: token header missing or path_id format invalid. + Response 400: token header missing or invalid, weak token, or path_id format invalid. Response 403: token does not match the owner token. Response 404: no learning path found for this path_id. """ @@ -1011,13 +1014,14 @@ def update_path(path_id): Request headers: X-Learning-Path-Token (required) - the token associated with this - path when it was created. + path when it was created. Must be at least + 16 characters long. Request body (JSON): Any JSON object representing the new learning-path state. Response 200: {"path_id": "", "message": "Learning path updated."} - Response 400: malformed request body, missing token, or invalid format. + Response 400: malformed request body, missing or weak token, or invalid format. Response 403: token does not match the owner token. Response 404: no learning path found for this path_id. """ @@ -1154,6 +1158,8 @@ def get_path_analytics(path_id): Calculate time analytics and velocity for a specific learning path. Requires X-Learning-Path-Token header for authorization. + The token must be at least 16 characters long (a random UUID or + similar high-entropy value); weak tokens are rejected. Expected data structure in path['progress']: { "project_id": {"completed": bool, "actual_hours": float} diff --git a/src/utils/learning_path.py b/src/utils/learning_path.py index 6ead6299..0e5f53d9 100644 --- a/src/utils/learning_path.py +++ b/src/utils/learning_path.py @@ -38,6 +38,10 @@ # Maximum byte length accepted for a path_id to prevent abuse _MAX_PATH_ID_LEN = 128 +# Minimum length required for the client-chosen ownership token so that +# trivially guessable secrets (e.g. "test", "1234") are rejected. +_MIN_TOKEN_LENGTH = 16 + # Regex that path_id values must satisfy (alphanumeric + hyphens/underscores) _PATH_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") @@ -76,9 +80,20 @@ def _validate_path_id(path_id: str) -> None: def _validate_token(token: str) -> None: - """Raise ValueError if token is not a non-empty string.""" + """Raise ValueError if token is not a sufficiently strong secret. + + Tokens are client-chosen but act as the only authorization secret for a + learning path, so a minimum length is enforced to reject trivially + guessable values. Clients should supply a random UUID or equivalent + high-entropy string. + """ if not isinstance(token, str) or not token.strip(): raise ValueError("token must be a non-empty string.") + if len(token) < _MIN_TOKEN_LENGTH: + raise ValueError( + f"token must be at least {_MIN_TOKEN_LENGTH} characters long; " + "use a random UUID or similar high-entropy value." + ) def _validate_data(data: dict) -> None: diff --git a/tests/test_learning_path.py b/tests/test_learning_path.py index 90fd9df8..402c4b65 100644 --- a/tests/test_learning_path.py +++ b/tests/test_learning_path.py @@ -578,6 +578,65 @@ def test_put_malformed_json_returns_400(self): assert response.status_code == 400 +class TestWeakTokenRejection: + """Issue #1874: trivially weak client-chosen tokens must be rejected.""" + + def setup_method(self): + _clear_all() + + def test_post_weak_token_returns_400(self): + """Creating a path with a short token must be rejected with 400.""" + client = get_client() + response = client.post( + "/api/learning-path/weak-create", + json={"step": 1}, + headers={TOKEN_HEADER: "test"}, + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + def test_post_numeric_weak_token_returns_400(self): + """Creating a path with a 4-character numeric token must be rejected.""" + client = get_client() + response = client.post( + "/api/learning-path/weak-num", + json={"step": 1}, + headers={TOKEN_HEADER: "1234"}, + ) + assert response.status_code == 400 + + def test_get_weak_token_returns_400(self): + """Reading with a weak token must be rejected with 400.""" + token = make_token() + self._seed("weak-read", token, {"step": 1}) + client = get_client() + response = client.get( + "/api/learning-path/weak-read", + headers={TOKEN_HEADER: "abc"}, + ) + assert response.status_code == 400 + + def test_put_weak_token_returns_400(self): + """Updating with a weak token must be rejected with 400.""" + token = make_token() + self._seed("weak-upd", token, {"step": 1}) + client = get_client() + response = client.put( + "/api/learning-path/weak-upd", + json={"step": 2}, + headers={TOKEN_HEADER: "short"}, + ) + assert response.status_code == 400 + + def _seed(self, path_id, token, data): + client = get_client() + client.post( + f"/api/learning-path/{path_id}", + json=data, + headers={TOKEN_HEADER: token}, + ) + + # --------------------------------------------------------------------------- # Run tests directly (no pytest required) # --------------------------------------------------------------------------- @@ -592,6 +651,7 @@ def test_put_malformed_json_returns_400(self): TestCreatePathRoute, TestReadPathRoute, TestUpdatePathRoute, + TestWeakTokenRejection, ] passed = 0