Preserve mobile share capture through sign-in #314

Merged
timmy merged 1 commits from timmy/313-preserve-mobile-share-sign-in into main 2026-08-08 15:20:02 +00:00
6 changed files with 226 additions and 9 deletions

View File

@ -2,15 +2,33 @@
if (typeof module === 'object' && module.exports) module.exports = factory; if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createLoginController = factory; else root.createLoginController = factory;
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) { }(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
const invalid = entries.some(([name, content]) => (
!Object.prototype.hasOwnProperty.call(limits, name)
|| !content
|| content.length > limits[name]
));
if (invalid) return './';
return value;
}
const form = options.form; const form = options.form;
const status = options.status; const status = options.status;
const button = options.button; const button = options.button;
const fetchImpl = options.fetchImpl; const fetchImpl = options.fetchImpl;
const location = options.location; const location = options.location;
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval; const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null; let timer = null;
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
function showReason(reason) { function showReason(reason) {
if (reason !== 'session-expired') return; if (reason !== 'session-expired') return;
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
@ -49,7 +67,7 @@
} }
form.reset(); form.reset();
if (response.ok) { if (response.ok) {
location.replace('./'); location.replace(continuation);
return; return;
} }
if (response.status === 429) { if (response.status === 429) {
@ -66,14 +84,16 @@ if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in'); const form = document.getElementById('sign-in');
const status = document.getElementById('status'); const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in'); const button = document.getElementById('submit-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({ const controller = createLoginController({
form, form,
status, status,
button, button,
fetchImpl: fetch.bind(window), fetchImpl: fetch.bind(window),
location: window.location, location: window.location,
continuation: loginParams.get('continue'),
}); });
controller.showReason(new URLSearchParams(window.location.search).get('reason')); controller.showReason(loginParams.get('reason'));
form.addEventListener('submit', event => { form.addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
const accessToken = new FormData(form).get('access_token'); const accessToken = new FormData(form).get('access_token');

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v31'; const CACHE = 'stackchain-dashboard-shell-v32';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,
@ -163,7 +163,9 @@ self.addEventListener('fetch', event => {
event.respondWith( event.respondWith(
fetch(request).then(async response => { fetch(request).then(async response => {
const cache = await caches.open(CACHE); const cache = await caches.open(CACHE);
if (response.ok) { const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone()); await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) { } else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE); const cached = await cache.match(BASE);

View File

@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import urlencode
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
@ -452,6 +453,24 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router) app.include_router(frontend_router)
def _share_target_login_redirect(request: Request) -> str:
limits = {"title": 200, "text": 8000, "url": 2048}
if any(
len(request.query_params.get(name, "")) > limit
for name, limit in limits.items()
):
return "login"
shared = [
(name, request.query_params[name])
for name in limits
if request.query_params.get(name)
]
if dashboard_auth.application_path(request) != "/" or not shared:
return "login"
continuation = f"./?{urlencode(shared)}"
return f"login?{urlencode({'continue': continuation})}"
@app.middleware("http") @app.middleware("http")
async def require_operator_session(request: Request, call_next): async def require_operator_session(request: Request, call_next):
path = dashboard_auth.application_path(request) path = dashboard_auth.application_path(request)
@ -496,7 +515,11 @@ async def require_operator_session(request: Request, call_next):
status_code=401, status_code=401,
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"}) return RedirectResponse(
_share_target_login_redirect(request),
status_code=303,
headers={"Cache-Control": "no-store"},
)
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}: if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
supplied_csrf = request.headers.get("x-csrf-token", "") supplied_csrf = request.headers.get("x-csrf-token", "")

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import time import time
from urllib.parse import parse_qs, urlsplit
import httpx import httpx
import pytest import pytest
@ -100,6 +101,43 @@ async def test_anonymous_private_request_is_rejected_before_gitea(access_control
assert called is False assert called is False
@pytest.mark.anyio
async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fields(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/",
params={
"title": "Production crash",
"text": "Steps from the mobile app",
"url": "https://example.com/incidents/42",
"next": "https://evil.example/steal",
},
)
assert response.status_code == 303
login_query = parse_qs(urlsplit(response.headers["location"]).query)
assert login_query == {
"continue": [
"./?title=Production+crash&text=Steps+from+the+mobile+app&url="
"https%3A%2F%2Fexample.com%2Fincidents%2F42"
]
}
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/", params={"title": "x" * 201, "text": "keep me"}
)
assert response.status_code == 303
assert response.headers["location"] == "login"
@pytest.mark.anyio @pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control): async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)

View File

@ -78,6 +78,122 @@ const controller = createLoginController({{
} }
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ replaced: null }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => state.replaced = value }},
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
}});
(async () => {{
await controller.submit('operator-token');
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["replaced"] == (
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
)
def test_shared_capture_login_explains_why_sign_in_is_required():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert result.stdout == "Sign in to continue your shared capture."
def test_login_rejects_untrusted_or_non_share_continuations():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
crossOrigin: await destination('https://evil.example/steal'),
otherRoute: await destination('./settings?title=Shared'),
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
prototypeField: await destination('./?toString=Shared'),
oversized: await destination('./?title=' + 'x'.repeat(201)),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"crossOrigin": "./",
"otherRoute": "./",
"extraField": "./",
"prototypeField": "./",
"oversized": "./",
}
def test_login_bootstrap_continues_shared_capture_after_submit():
harness = f"""
const fs = require('fs');
const vm = require('vm');
const state = {{ replaced: null, submit: null }};
const elements = {{
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
'status': {{ textContent: '' }},
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
window: {{ location: {{
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
replace: value => state.replaced = value,
}} }},
}};
context.fetch.bind = Function.prototype.bind;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
state.submit({{ preventDefault: () => {{}} }});
setTimeout(() => process.stdout.write(JSON.stringify({{
replaced: state.replaced, status: elements.status.textContent,
}})), 0);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
"status": "Signing in…",
}
@pytest.mark.anyio @pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller(): async def test_login_page_loads_rate_limit_controller():
html = await login() html = await login()

View File

@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs'); const fs = require('fs');
const vm = require('vm'); const vm = require('vm');
const listeners = {{}}; const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const cache = {{ const cache = {{
addAll: async urls => {{ state.added = urls; }}, addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@ -48,7 +48,9 @@ const context = {{
fetch: async request => {{ fetch: async request => {{
state.fetches.push(String(request.url || request)); state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline'); if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }}); const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response;
}}, }},
}}; }};
vm.createContext(context); vm.createContext(context);
@ -92,10 +94,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_session_expiry_recovery_ships_in_a_new_shell_cache(): def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v31" in source assert "stackchain-dashboard-shell-v32" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -301,6 +303,22 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
] ]
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario(
"""
state.fetchRedirected = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared',
});
process.stdout.write(JSON.stringify({ status: response.status, state }));
"""
)
assert result["status"] == 200
assert result["state"]["puts"] == []
def test_cross_origin_navigation_is_not_intercepted_or_cached(): def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario( result = run_worker_scenario(
""" """