From 407b213222ae7e5e129ba12f196018b0a6745163 Mon Sep 17 00:00:00 2001 From: nearology Date: Thu, 5 Nov 2026 18:49:21 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A7=D8=B5=D9=84=D8=A7=D8=AD=20=D9=85=D8=B4?= =?UTF-8?q?=DA=A9=D9=84=20CORS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 9 +++++ backend/tests/test_platform.py | 62 ++++++++++++++++++++++++++++++++++ docs/API.md | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 58e5194..d273e1e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Annotated, Literal from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, Response from sqlalchemy import func, select, text from sqlalchemy.exc import SQLAlchemyError @@ -80,6 +81,14 @@ async def security(request, call_next): }) return response +# Register after security so CORS also decorates its early authentication errors. +app.add_middleware( + CORSMiddleware, + allow_origins=['https://git.sadentel.net'], + allow_methods=['GET', 'POST', 'OPTIONS'], + allow_headers=['X-API-Key', 'X-Viewer-Token', 'Content-Type'], +) + @app.exception_handler(HTTPException) async def http_error(request, exc): detail = exc.detail if isinstance(exc.detail, dict) else {'code': 'http_error', 'message': str(exc.detail)} diff --git a/backend/tests/test_platform.py b/backend/tests/test_platform.py index 4350b3d..7d8f552 100644 --- a/backend/tests/test_platform.py +++ b/backend/tests/test_platform.py @@ -163,3 +163,65 @@ def test_parser_wall_timeout_is_reported(client,ascii_schematic,monkeypatch): process(job) status=client.get(f"/api/v1/schematics/{body['id']}/status",headers={'X-API-Key':KEY}).json() assert status['status']=='failed' and status['error']['code']=='parse_timeout' + + +CORS_ORIGIN = 'https://git.sadentel.net' + +@pytest.mark.parametrize('requested_headers', ['x-api-key', 'X-API-Key, Content-Type']) +def test_gitea_preflight_without_api_key(client, requested_headers): + response = client.options('/api/v1/schematics/render', headers={ + 'Origin': CORS_ORIGIN, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': requested_headers, + }) + assert response.status_code == 200, response.text + assert response.headers['access-control-allow-origin'] == CORS_ORIGIN + assert {'POST', 'OPTIONS'} <= set(response.headers['access-control-allow-methods'].split(', ')) + allowed = {h.strip().lower() for h in response.headers['access-control-allow-headers'].split(',')} + assert {'x-api-key', 'content-type'} <= allowed + assert 'Origin' in response.headers['vary'] + assert 'access-control-allow-credentials' not in response.headers + + +@pytest.mark.parametrize('origin', ['https://evil.example', 'https://git.sadentel.net.evil.example', 'http://git.sadentel.net']) +def test_cors_rejects_other_origins(client, origin): + response = client.options('/api/v1/schematics/render', headers={ + 'Origin': origin, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'x-api-key', + }) + assert response.status_code == 400 + assert 'access-control-allow-origin' not in response.headers + + +@pytest.mark.parametrize('key', [None, 'invalid-key', KEY]) +def test_gitea_post_retains_authentication_and_cors(client, ascii_schematic, key): + headers = {'Origin': CORS_ORIGIN} + if key is not None: headers['X-API-Key'] = key + response = client.post('/api/v1/schematics/render', headers=headers, + files={'file': ('design.SchDoc', ascii_schematic)}, data={'access': 'private'}) + assert response.status_code == (202 if key == KEY else 401) + assert response.headers['access-control-allow-origin'] == CORS_ORIGIN + + +def test_gitea_private_status_read(client, ascii_schematic): + created = upload(client, ascii_schematic).json() + endpoint = f"/api/v1/schematics/{created['id']}/status" + preflight = client.options(endpoint, headers={ + 'Origin': CORS_ORIGIN, 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'x-viewer-token', + }) + assert preflight.status_code == 200 + response = client.get(endpoint, headers={'Origin': CORS_ORIGIN, 'X-Viewer-Token': created['viewer_token']}) + assert response.status_code == 200 + assert response.headers['access-control-allow-origin'] == CORS_ORIGIN + assert client.get(endpoint, headers={'Origin': CORS_ORIGIN}).status_code == 404 + + +def test_cors_rejects_unneeded_methods_and_headers(client): + for method, header in [('DELETE', 'x-api-key'), ('POST', 'x-unapproved-header')]: + response = client.options('/api/v1/schematics/render', headers={ + 'Origin': CORS_ORIGIN, 'Access-Control-Request-Method': method, + 'Access-Control-Request-Headers': header, + }) + assert response.status_code == 400 diff --git a/docs/API.md b/docs/API.md index fde1e4c..242d1e2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # REST API contract -All routes are same-origin by default. Authentication for upload is `X-API-Key`. Private reads accept the same service key or `X-Viewer-Token`, returned only at creation. The service uses one integration key, not multi-tenant user authentication. Use a gateway for organization-specific account roles, key rotation, quotas and rate limits. +Cross-origin browser access is allowed only from `https://git.sadentel.net`, with GET, POST and OPTIONS and the request headers `X-API-Key`, `X-Viewer-Token` and `Content-Type` (plus the framework's standard safelisted headers). Preflight OPTIONS requires no API key; POST authentication and private-read permissions remain enforced. Cookie credentials are not enabled. Authentication for upload is `X-API-Key`. Private reads accept the same service key or `X-Viewer-Token`, returned only at creation. The service uses one integration key, not multi-tenant user authentication. Use a gateway for organization-specific account roles, key rotation, quotas and rate limits. `POST /api/v1/schematics/render` accepts exactly one multipart file field `file`, with `.SchDoc` suffix (case insensitive). The client-provided content type is not trusted. The request body is capped before multipart spooling, and extracted file bytes are separately capped. Initial format identification accepts OLE signature or an ASCII SchDoc header; complete structural/version validation runs asynchronously in an isolated worker.