security: keep mobile share content private (Closes #903)
CI / lint (pull_request) Successful in 1m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-15 16:02:35 +00:00
1 parent 06184f1cb2
commit fdd3cf03b4
18 files changed
+231 -87

No files matched your search

+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v105" in worker
assert "stackchain-dashboard-shell-v106" in worker
+5 -12
View File
@@ -944,33 +944,26 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access
@pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
async def test_anonymous_shared_bundle_preserves_only_opaque_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get(
"/",
params={
"launch": "new",
"shared": "image",
"title": "Broken checkout",
"text": "Steps to reproduce",
},
params={"launch": "new", "shared": "bundle"},
)
wrong_launch = await client.get(
"/", params={"launch": "agenda", "shared": "image"}
"/", params={"launch": "agenda", "shared": "bundle"}
)
wrong_marker = await client.get(
"/", params={"launch": "new", "shared": "document"}
)
duplicate_marker = await client.get(
"/?launch=new&shared=image&shared=image"
"/?launch=new&shared=bundle&shared=bundle"
)
assert valid.status_code == 303
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": [
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
]
"continue": ["./?launch=new&shared=bundle"]
}
assert wrong_launch.headers["location"] == "login"
assert wrong_marker.headers["location"] == "login"
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v105" in worker
assert "stackchain-dashboard-shell-v106" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v105" in worker
assert "stackchain-dashboard-shell-v106" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v105" in worker
assert "stackchain-dashboard-shell-v106" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v105" in service_worker
assert "stackchain-dashboard-shell-v106" in service_worker
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+98 -17
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -49,7 +49,10 @@ const context = {{
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}),
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
put: async (id, value) => {{
if (state.failSharedPut) throw new Error('quota');
state.sharedRecords[id] = value;
}},
get: async id => state.sharedRecords[id] || null,
delete: async id => {{ delete state.sharedRecords[id]; }},
}},
@@ -152,7 +155,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -161,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -176,7 +179,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -184,14 +187,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -200,21 +203,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -228,21 +231,29 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
form.append('text', 'Steps to reproduce');
form.append('url', 'https://example.test/checkout');
form.append('image', image, 'bug.png');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
const request = new Request('https://forge.example/dashboard/share-target', {
method:'POST', body:form, headers:{'Sec-Fetch-Site':'none'},
});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const [id, record] = Object.entries(state.sharedRecords)[0];
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), id,
filename:record.filename, contentType:record.contentType, size:record.blob.size,
title:record.title, text:record.text, url:record.url,
filename:record.attachments[0].filename,
contentType:record.attachments[0].contentType,
size:record.attachments[0].blob.size,
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout",
"location": "/dashboard/?launch=new&shared=bundle",
"id": "shared-image",
"title": "Broken checkout",
"text": "Steps to reproduce",
"url": "https://example.test/checkout",
"filename": "bug.png",
"contentType": "image/png",
"size": 6,
@@ -255,7 +266,9 @@ def test_image_share_target_stages_ordered_evidence_bundle():
const form = new FormData();
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
const request = new Request('https://forge.example/dashboard/share-target', {
method:'POST', body:form, headers:{'Sec-Fetch-Site':'same-origin'},
});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const record=state.sharedRecords['shared-image'];
@@ -268,12 +281,80 @@ def test_image_share_target_stages_ordered_evidence_bundle():
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=images",
"location": "/dashboard/?launch=new&shared=bundle",
"names": ["one.png", "two.jpg"],
"sizes": [3, 3],
}
def test_share_target_rejects_cross_site_posts_without_touching_staged_bundle():
result = run_worker_scenario(
"""
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
const form=new FormData();form.append('title','Replace me');
const request=new Request('https://forge.example/dashboard/share-target',{
method:'POST',body:form,headers:{'Sec-Fetch-Site':'cross-site'},
});
Object.defineProperty(request,'mode',{value:'navigate'});
const response=await dispatch('fetch',request);
process.stdout.write(JSON.stringify({status:response.status,record:state.sharedRecords['shared-image']}));
"""
)
assert result == {
"status": 403,
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
}
def test_invalid_share_target_preserves_the_previous_atomic_bundle():
result = run_worker_scenario(
"""
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
const form=new FormData();
form.append('image',new Blob(['payload'],{type:'application/pdf'}),'secret.pdf');
const request=new Request('https://forge.example/dashboard/share-target',{
method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'},
});
Object.defineProperty(request,'mode',{value:'navigate'});
const response=await dispatch('fetch',request);
process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']}));
"""
)
assert result == {
"location": "/dashboard/?launch=new&shared=unsupported",
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
}
def test_share_target_storage_failure_preserves_bundle_and_returns_recovery_marker():
result = run_worker_scenario(
"""
state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]};
state.failSharedPut=true;
const form=new FormData();form.append('title','New private content');
const request=new Request('https://forge.example/dashboard/share-target',{
method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'},
});
Object.defineProperty(request,'mode',{value:'navigate'});
const response=await dispatch('fetch',request);
process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']}));
"""
)
assert result == {
"location": "/dashboard/?launch=new&shared=unavailable",
"record": {"title": "Keep me", "text": "private", "url": "", "attachments": []},
}
def test_share_target_manifest_uses_dedicated_admission_path():
manifest = json.loads((WORKER.parent / "manifest.webmanifest").read_text())
assert manifest["share_target"]["action"] == "./share-target"
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
result = run_worker_scenario(
"""
@@ -800,7 +881,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/queue-today.js'" in source
+25 -1
View File
@@ -68,4 +68,28 @@ shared.consume({{
assert json.loads(completed.stdout) == {
"result": False,
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
}
}
def test_private_share_bundle_restores_metadata_and_ordered_images_once():
script = f"""
const shared=require({json.dumps(str(MODULE))});const calls=[];
const store={{get:async()=>({{
title:'Broken checkout',text:'Steps to reproduce',url:'https://example.test/private',
attachments:[{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}}],
}}),delete:async id=>calls.push(['delete',id])}};
(async()=>{{const result=await shared.consume({{
marker:'bundle',store,
restore:value=>calls.push(['restore',value.map(x=>x.filename)]),
restoreContent:value=>calls.push(['content',value]),
status:value=>calls.push(['status',value]),
}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
"""
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert output == {"result": True, "calls": [
["content", {"title": "Broken checkout", "text": "Steps to reproduce", "url": "https://example.test/private"}],
["restore", ["one.png"]],
["delete", "shared-image"],
["status", "Shared content and screenshot ready to file with this issue."],
]}
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v105';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v106';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v105" in source
assert "stackchain-dashboard-shell-v106" in source
assert "BASE + 'static/today-sync.js'" in source
+4 -1
View File
@@ -103,7 +103,7 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert manifest.json()["start_url"] == "./"
assert manifest.json()["scope"] == "./"
assert manifest.json()["share_target"] == {
"action": "./", "method": "POST", "enctype": "multipart/form-data",
"action": "./share-target", "method": "POST", "enctype": "multipart/form-data",
"params": {
"title": "title", "text": "text", "url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
@@ -137,6 +137,9 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert "new URL('./', self.location.href).pathname" in worker.text
assert "acceptSharedContent(request)" in worker.text
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
dashboard = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert "restoreContent:content=>" in dashboard
assert "issueCapture.stageSharedContent(content)" in dashboard
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()