@@ -74,11 +74,15 @@ async def security(request, call_next):
|
|||||||
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
|
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)
|
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
|
||||||
response = await call_next(request)
|
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({
|
response.headers.update({
|
||||||
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
'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=()',
|
'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
|
return response
|
||||||
|
|
||||||
# Register after security so CORS also decorates its early authentication errors.
|
# Register after security so CORS also decorates its early authentication errors.
|
||||||
|
|||||||
@@ -225,3 +225,59 @@ def test_cors_rejects_unneeded_methods_and_headers(client):
|
|||||||
'Access-Control-Request-Headers': header,
|
'Access-Control-Request-Headers': header,
|
||||||
})
|
})
|
||||||
assert response.status_code == 400
|
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
|
||||||
|
|||||||
مرجع در شماره جدید
مسدود کردن کاربر