diff --git a/devplacepy/models.py b/devplacepy/models.py index 9a9ac569..fa74ffe3 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -470,9 +470,19 @@ class SeoRunForm(BaseModel): text = value.strip() if not text: raise ValueError("A URL is required") + if "://" in text: + scheme = text.split("://", 1)[0] + if scheme not in ("http", "https"): + raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'") + else: + text = f"https://{text}" + if not SEO_URL_PATTERN.match(text): + raise ValueError("URL must be a valid http or https source location") return text +SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$") + ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE) ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE) ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) diff --git a/tests/api/tools/seo.py b/tests/api/tools/seo.py index f15e7f47..57676713 100644 --- a/tests/api/tools/seo.py +++ b/tests/api/tools/seo.py @@ -89,6 +89,53 @@ def test_run_clamps_max_pages_above_cap(app_server): _clear_seo_jobs() +def test_run_rejects_malformed_scheme(app_server): + r = requests.post( + f"{BASE_URL}/tools/seo/run", + headers=_json_headers(), + data={"url": "ahttps://devplace.net/sitem", "mode": "url"}, + allow_redirects=False, + ) + assert r.status_code in (400, 422), r.text + + +def test_run_rejects_relative_path(app_server): + r = requests.post( + f"{BASE_URL}/tools/seo/run", + headers=_json_headers(), + data={"url": "/feed", "mode": "url"}, + allow_redirects=False, + ) + assert r.status_code in (400, 422), r.text + + +def test_run_normalizes_missing_scheme(app_server): + try: + r = requests.post( + f"{BASE_URL}/tools/seo/run", + headers=_json_headers(), + data={"url": "example.com", "mode": "url", "max_pages": "5"}, + ) + assert r.status_code == 200, r.text + uid = r.json()["uid"] + refresh_snapshot() + job = queue.get_job(uid) + assert job is not None + assert job["payload"]["url"] == "https://example.com" + finally: + _clear_seo_jobs() + + +def test_run_rejects_non_http_scheme(app_server): + r = requests.post( + f"{BASE_URL}/tools/seo/run", + headers=_json_headers(), + data={"url": "ftp://example.com", "mode": "url"}, + allow_redirects=False, + ) + assert r.status_code in (400, 422), r.text + + def test_run_enforces_one_active_job_per_owner(app_server): try: first = requests.post(