From 8d5dfa971beb2df98f0f07d2150e578c8ba87908 Mon Sep 17 00:00:00 2001 From: nearology Date: Thu, 5 Nov 2026 19:00:55 +0330 Subject: [PATCH] =?UTF-8?q?=D8=AD=D9=84=20=D9=85=D8=B4=DA=A9=D9=84=20CORS?= =?UTF-8?q?=20IFRAME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 6 +++- backend/tests/test_platform.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index d273e1e..4f29366 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -74,11 +74,15 @@ async def security(request, call_next): if settings().api_key and not authenticated(request.headers.get('x-api-key')): return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401) response = await call_next(request) + is_viewer = request.url.path.startswith('/view/') + frame_ancestors = "'self' https://git.sadentel.net" if is_viewer else "'none'" response.headers.update({ 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', - 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + 'Content-Security-Policy': f"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; frame-ancestors {frame_ancestors}; form-action 'self'", 'Cache-Control': 'no-store', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', }) + if is_viewer and 'X-Frame-Options' in response.headers: + del response.headers['X-Frame-Options'] return response # Register after security so CORS also decorates its early authentication errors. diff --git a/backend/tests/test_platform.py b/backend/tests/test_platform.py index 7d8f552..dbefa52 100644 --- a/backend/tests/test_platform.py +++ b/backend/tests/test_platform.py @@ -225,3 +225,59 @@ def test_cors_rejects_unneeded_methods_and_headers(client): 'Access-Control-Request-Headers': header, }) assert response.status_code == 400 + + +DEFAULT_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" +VIEWER_CSP = DEFAULT_CSP.replace("frame-ancestors 'none'", "frame-ancestors 'self' https://git.sadentel.net") + +@pytest.mark.parametrize('method,path,expected_status', [ + ('GET', '/view/' + '0'*48, 200), + ('GET', '/view/invalid-id', 404), + ('GET', '/view/nested/missing', 404), + ('POST', '/view/' + '0'*48, 405), +]) +def test_viewer_only_allows_self_and_gitea_frames(client, method, path, expected_status): + response = client.request(method, path) + assert response.status_code == expected_status + assert response.headers.get_list('content-security-policy') == [VIEWER_CSP] + assert 'x-frame-options' not in response.headers + assert response.headers['x-content-type-options'] == 'nosniff' + + +@pytest.mark.parametrize('path', ['/', '/docs', '/healthz', '/assets/viewer.js', '/view', '/viewer/invalid', '/api/v1/schematics/'+'0'*48+'/status']) +def test_non_viewer_keeps_original_csp(client, path): + response = client.get(path) + assert response.headers.get_list('content-security-policy') == [DEFAULT_CSP] + + +@pytest.mark.parametrize('frame_option', ['DENY', 'SAMEORIGIN']) +def test_conflicting_headers_removed_only_for_viewer(client, monkeypatch, frame_option): + from fastapi.responses import Response + def page(*args, **kwargs): + response = Response('page', media_type='text/html', headers={ + 'X-Frame-Options': frame_option, 'Content-Security-Policy': DEFAULT_CSP, + }) + response.headers.append('Content-Security-Policy', "frame-ancestors 'none'") + return response + monkeypatch.setattr('app.main.FileResponse', page) + viewer = client.get('/view/'+'0'*48) + assert 'x-frame-options' not in viewer.headers + assert viewer.headers.get_list('content-security-policy') == [VIEWER_CSP] + other = client.get('/docs') + assert other.headers['x-frame-options'] == frame_option + assert other.headers.get_list('content-security-policy') == [DEFAULT_CSP] + + +def test_framed_viewer_does_not_bypass_private_token(client, ascii_schematic): + created = upload(client, ascii_schematic).json() + process(claim()) + identifier = created['id'] + assert client.get('/view/'+identifier).status_code == 200 + assert client.get('/view/'+identifier, headers={'Sec-Fetch-Dest':'iframe', 'Referer': CORS_ORIGIN+'/'}).status_code == 200 + for endpoint in ('status','data','svg'): + path=f'/api/v1/schematics/{identifier}/{endpoint}' + assert client.get(path).status_code == 404 + assert client.get(path, headers={'X-Viewer-Token':'invalid-token'}).status_code == 404 + response=client.get(path, headers={'X-Viewer-Token':created['viewer_token']}) + assert response.status_code == 200 + assert response.headers['content-security-policy'] == DEFAULT_CSP