diff --git a/qa/health-server-pagination.test.cjs b/qa/health-server-pagination.test.cjs index ca64951..b18b876 100644 --- a/qa/health-server-pagination.test.cjs +++ b/qa/health-server-pagination.test.cjs @@ -5,13 +5,13 @@ const fs=require('node:fs'); const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js'); function summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};} function fixture(){ - const nodes=new Map(),links=[],requests=[],calendars=[]; + const nodes=new Map(),links=[],forms=[],elements=[],requests=[],calendars=[]; const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);}; - const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(){const a={click(){links.push(this.href);},remove(){}};return a;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})}); + const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(tag){const element={tag,children:[],appendChild(child){this.children.push(child);},click(){links.push(this.href);},submit(){forms.push({method:this.method,action:this.action,fields:this.children.map(child=>({name:child.name,value:child.value,type:child.type}))});},remove(){this.removed=true;}};elements.push(element);return element;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})}); for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context); vm.runInContext('showToast=()=>{};state.activeTab="stress";',context); const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)}); - return {context,get,links,requests,calendars,reply,run:code=>vm.runInContext(code,context)}; + return {context,get,links,forms,elements,requests,calendars,reply,run:code=>vm.runInContext(code,context)}; } test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{ const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial; @@ -77,3 +77,14 @@ test('out-of-order overview loads preserve new range values and inspector',async f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML; f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/); }); + +for(const source of ['input','state'])test(`native export submits the selected ${source} key only in a temporary POST form`,async()=>{ + const f=fixture(); + f.get('apiKeyInput').value=' synthetic-input-key '; + if(source==='state')f.run('state.apiKey="synthetic-state-key"'); + const selected=source==='state'?'synthetic-state-key':'synthetic-input-key'; + const initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + f.run('exportHealthRecords("stress")');assert.equal(f.forms.length,1);assert.equal(f.links.length,0);assert.equal(f.requests.length,1); + const form=f.forms[0];assert.equal(form.method,'POST');assert.match(form.action,/^\/proxy\/api\/views\/stress\/export\?/);assert.doesNotMatch(form.action,/synthetic|api_key|page=/);assert.deepEqual(form.fields,[{name:'api_key',value:selected,type:'hidden'}]); + const node=f.elements.find(element=>element.tag==='form');assert.equal(node.removed,true);assert.equal(node.children[0].value,'');assert.equal(node.autocomplete,'off'); +}); diff --git a/src/mi_fitness_mcp/web.py b/src/mi_fitness_mcp/web.py index c68051f..d946e62 100644 --- a/src/mi_fitness_mcp/web.py +++ b/src/mi_fitness_mcp/web.py @@ -155,6 +155,8 @@ def create_app( # Attach API Key (Server env priority, otherwise pass through client key) server_api_key = app.config["API_KEY"] if server_api_key: + forward_headers = {key: value for key, value in forward_headers.items() + if key.lower() != "x-api-key"} forward_headers["X-API-Key"] = server_api_key # Attach Admin Key if configured @@ -166,12 +168,22 @@ def create_app( forward_headers = {key: value for key, value in forward_headers.items() if key.lower() != "x-api-key"} - body = request.get_data() if request.method in ("POST", "PUT", "PATCH", "DELETE") else None + parts = clean_subpath.split("/") + is_export = (request.method in {"GET", "POST"} and len(parts) == 4 + and parts[:2] == ["api", "views"] and parts[3] == "export" + and parts[2] in {"activity", "heartrate", "sleep", "workouts", "body", "spo2", "stress", "abnormal"}) + body = request.get_data() if not is_export and request.method in ("POST", "PUT", "PATCH", "DELETE") else None started = time.perf_counter() try: - parts = clean_subpath.split("/") - if request.method == "GET" and len(parts) == 4 and parts[:2] == ["api", "views"] and parts[3] == "export": + if is_export: + # 仅导出接受原生下载表单;上游仍为无请求体的只读 GET。 + export_key = server_api_key or (request.form.get("api_key") if request.method == "POST" else None) + if export_key: + forward_headers = {key: value for key, value in forward_headers.items() if key.lower() != "x-api-key"} + forward_headers["X-API-Key"] = export_key + if request.method == "POST": + forward_headers = {key: value for key, value in forward_headers.items() if key.lower() != "content-type"} upstream_request = http_client.build_request("GET", target_url, headers=forward_headers) upstream_resp = http_client.send(upstream_request, stream=True) elapsed_ms = round((time.perf_counter() - started) * 1000, 2) diff --git a/src/mi_fitness_mcp/web_assets/static/js/health-ui.js b/src/mi_fitness_mcp/web_assets/static/js/health-ui.js index ef3c893..a87ff0c 100644 --- a/src/mi_fitness_mcp/web_assets/static/js/health-ui.js +++ b/src/mi_fitness_mcp/web_assets/static/js/health-ui.js @@ -109,8 +109,18 @@ async function healthPage(kind,page){ } function exportHealthRecords(kind){ const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return; + const path='/proxy'+healthPath(kind,'/export'),key=getHeaders()['X-API-Key']; + if(key){ + // 原生附件下载通过短暂表单传查询身份,Key 不进入 URL 或长期 DOM。 + const form=document.createElement('form'),input=document.createElement('input'); + form.method='POST';form.action=path;form.hidden=true;form.autocomplete='off'; + input.type='hidden';input.name='api_key';input.value=key; + form.appendChild(input);document.body.appendChild(form); + try{form.submit();}finally{input.value='';form.remove();} + return; + } const link=document.createElement('a'); - link.href='/proxy'+healthPath(kind,'/export');link.download=''; + link.href=path;link.download=''; document.body.appendChild(link);link.click();link.remove(); } function initHealthViews(){ diff --git a/tests/test_web_streaming.py b/tests/test_web_streaming.py index 3c3b493..765728c 100644 --- a/tests/test_web_streaming.py +++ b/tests/test_web_streaming.py @@ -29,13 +29,14 @@ def make_client(monkeypatch): return app.test_client() +@pytest.mark.parametrize("method", ["GET", "POST"]) @respx.mock -def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch): +def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch, method): stream = TrackedStream() route = respx.get("http://synthetic-backend/api/views/stress/export").mock(return_value=httpx.Response(200, stream=stream, headers={"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="synthetic.csv"', "X-API-Key": "must-not-leak", "X-Admin-Key": "must-not-leak", "Authorization": "must-not-leak", "Set-Cookie": "must-not-leak"})) client = make_client(monkeypatch) - response = client.get("/proxy/api/views/stress/export", auth=("admin", "synthetic-web-password"), buffered=False) + response = client.open("/proxy/api/views/stress/export", method=method, data={"api_key": "synthetic-browser-key"} if method == "POST" else None, auth=("admin", "synthetic-web-password"), buffered=False) assert response.status_code == 200 assert response.is_streamed assert 0 < stream.consumed < 30 @@ -48,13 +49,14 @@ def test_stream_disconnect_closes_cursor_and_proxy_response(monkeypatch): assert stream.consumed < 30 +@pytest.mark.parametrize("method", ["GET", "POST"]) @respx.mock -def test_stream_normal_completion_and_error_release_upstream(monkeypatch): +def test_stream_normal_completion_and_error_release_upstream(monkeypatch, method): client = make_client(monkeypatch) for fail in [False, True]: stream = TrackedStream(fail) respx.get("http://synthetic-backend/api/views/body/export").mock(return_value=httpx.Response(200, stream=stream, headers={"Content-Type": "text/csv"})) - response = client.get("/proxy/api/views/body/export", auth=("admin", "synthetic-web-password"), buffered=False) + response = client.open("/proxy/api/views/body/export", method=method, data={"api_key": "synthetic-browser-key"} if method == "POST" else None, auth=("admin", "synthetic-web-password"), buffered=False) if fail: with pytest.raises(httpx.ReadError): list(response.response) @@ -78,3 +80,78 @@ def test_export_connection_failure_uses_existing_error_shape(monkeypatch): assert response.status_code == 502 assert response.json["error"] == "Flask 代理无法访问后端" assert "X-Upstream-Time-Ms" in response.headers + + +@respx.mock +def test_form_export_uses_the_same_selected_user_as_summary_without_key_in_url(monkeypatch): + monkeypatch.setenv("MI_FITNESS_WEB_PASSWORD", "synthetic-web-password") + monkeypatch.delenv("MI_FITNESS_API_KEY", raising=False) + monkeypatch.delenv("MI_FITNESS_ADMIN_KEY", raising=False) + client = create_app(backend_url="http://synthetic-backend").test_client() + selected_key = "synthetic-selected-key" + seen = [] + + def user_response(request): + selected = request.headers.get("X-API-Key") == selected_key + seen.append(request) + if not selected: + return httpx.Response(401, json={"status": "error"}) + if request.url.path.endswith("/export"): + return httpx.Response(200, content=b"synthetic-selected-user,csv", headers={"Content-Type": "text/csv", "Content-Disposition": 'attachment; filename="synthetic.csv"'}) + return httpx.Response(200, json={"status": "ok", "synthetic_user": "synthetic-selected-user"}) + + respx.get("http://synthetic-backend/api/views/stress").mock(side_effect=user_response) + respx.get("http://synthetic-backend/api/views/stress/export").mock(side_effect=user_response) + auth = ("admin", "synthetic-web-password") + query = "?start_date=2026-10-01&end_date=2026-10-17&pressure_level=mild&limit=11" + summary = client.get("/proxy/api/views/stress" + query, auth=auth, headers={"X-API-Key": selected_key}) + export = client.post("/proxy/api/views/stress/export" + query, data={"api_key": selected_key, "unexpected": "must-not-forward"}, auth=auth) + assert summary.status_code == export.status_code == 200 + assert summary.json["synthetic_user"].encode() in export.data + assert len(seen) == 2 + assert all(request.headers["X-API-Key"] == selected_key for request in seen) + assert seen[1].method == "GET" + assert seen[1].content == b"" + assert selected_key not in str(seen[1].url) + assert "unexpected" not in str(seen[1].url) + assert seen[1].url.query == seen[0].url.query + assert "application/x-www-form-urlencoded" not in seen[1].headers.get("Content-Type", "") + export.close() + + +@respx.mock +def test_server_key_wins_over_form_and_browser_header(monkeypatch): + client = make_client(monkeypatch) + summary_route = respx.get("http://synthetic-backend/api/views/activity").mock(return_value=httpx.Response(200, json={"status": "ok"})) + summary = client.get("/proxy/api/views/activity", headers={"X-API-Key": "synthetic-header-key"}, auth=("admin", "synthetic-web-password")) + assert summary.status_code == 200 + assert summary_route.calls[0].request.headers.get_list("X-API-Key") == ["synthetic-api-key"] + route = respx.get("http://synthetic-backend/api/views/activity/export").mock(return_value=httpx.Response(200, content=b"synthetic", headers={"Content-Type": "text/csv"})) + response = client.post("/proxy/api/views/activity/export", data={"api_key": "synthetic-form-key"}, headers={"X-API-Key": "synthetic-header-key"}, auth=("admin", "synthetic-web-password")) + assert response.status_code == 200 + assert route.calls[0].request.headers.get_list("X-API-Key") == ["synthetic-api-key"] + assert route.calls[0].request.content == b"" + response.close() + + +@respx.mock +def test_only_whitelisted_exports_translate_post_and_form_key(monkeypatch): + client = make_client(monkeypatch) + for path in ["api/views/not-a-kind/export", "api/views/stress/records", "api/sync"]: + route = respx.post("http://synthetic-backend/" + path).mock(return_value=httpx.Response(200, json={"status": "ok"})) + response = client.post("/proxy/" + path, data={"api_key": "synthetic-form-key"}, auth=("admin", "synthetic-web-password")) + assert response.status_code == 200 + request = route.calls[0].request + assert request.method == "POST" + assert request.content == b"api_key=synthetic-form-key" + assert request.headers["X-API-Key"] == "synthetic-api-key" + + +@respx.mock +def test_form_export_retains_basic_host_and_origin_checks(monkeypatch): + client = make_client(monkeypatch) + path = "/proxy/api/views/stress/export" + assert client.post(path, data={"api_key": "synthetic"}).status_code == 401 + auth = ("admin", "synthetic-web-password") + assert client.post(path, data={"api_key": "synthetic"}, auth=auth, headers={"Origin": "https://untrusted.example"}).status_code == 403 + assert client.post(path, data={"api_key": "synthetic"}, auth=auth, headers={"Host": "untrusted.example"}).status_code == 403