diff --git a/bases/rsptx/book_server_api/routers/rsproxy.py b/bases/rsptx/book_server_api/routers/rsproxy.py index df8950525..2b32d25ca 100644 --- a/bases/rsptx/book_server_api/routers/rsproxy.py +++ b/bases/rsptx/book_server_api/routers/rsproxy.py @@ -61,6 +61,14 @@ # The tracer keeps the 30s budget the previous ``requests`` call used. TRACE_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=10.0) IMAGE_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0) +EXPERIMENT_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) +MAX_EXPERIMENT_RESPONSE_BYTES = 1024 * 1024 + +# Only named, reviewed destinations can be reached through the book server. +# Add new experiment APIs here rather than accepting a URL from the browser. +EXPERIMENT_TARGETS = { + ("rdaas", "api/duck"): "https://rdaas.flypages.dev/api/duck", +} # Shared clients, created on first use and reused thereafter: that is what keeps # connections pooled and TLS handshakes amortized across requests. Building one @@ -107,6 +115,19 @@ def image_client() -> httpx.AsyncClient: return _client("image", timeout=IMAGE_TIMEOUT, follow_redirects=False) +def experiment_client() -> httpx.AsyncClient: + """Client for explicitly allowlisted experiment APIs.""" + async def strip_cookies(request: httpx.Request) -> None: + request.headers.pop("cookie", None) + + return _client( + "experiment", + timeout=EXPERIMENT_TIMEOUT, + follow_redirects=False, + event_hooks={"request": [strip_cookies]}, + ) + + async def close_clients() -> None: """Close every shared client. Called from the app's lifespan shutdown.""" for name, client in list(_clients.items()): @@ -117,6 +138,62 @@ async def close_clients() -> None: _clients.clear() +@router.get("/experiments/{service}/{path:path}") +async def experiment_api(request: Request, service: str, path: str): + """Tunnel an allowlisted GET API through the book server's origin.""" + target = EXPERIMENT_TARGETS.get((service, path)) + if target is None: + return JSONResponse( + status_code=404, content={"error": "Unknown experiment API."} + ) + + try: + async with experiment_client().stream( + "GET", target, params=list(request.query_params.multi_items()) + ) as upstream: + if 300 <= upstream.status_code < 400: + return JSONResponse( + status_code=502, content={"error": "Experiment API redirected."} + ) + declared = upstream.headers.get("Content-Length") + if ( + declared + and declared.isdigit() + and int(declared) > MAX_EXPERIMENT_RESPONSE_BYTES + ): + return JSONResponse( + status_code=502, + content={"error": "Experiment API response is too large."}, + ) + chunks = [] + size = 0 + async for chunk in upstream.aiter_bytes(): + size += len(chunk) + if size > MAX_EXPERIMENT_RESPONSE_BYTES: + return JSONResponse( + status_code=502, + content={"error": "Experiment API response is too large."}, + ) + chunks.append(chunk) + content_type = upstream.headers.get( + "Content-Type", "application/octet-stream" + ) + return Response( + content=b"".join(chunks), + status_code=upstream.status_code, + headers={"Content-Type": content_type, "Cache-Control": "no-store"}, + ) + except httpx.TimeoutException: + return JSONResponse( + status_code=504, content={"error": "Experiment API timed out."} + ) + except httpx.HTTPError as e: + rslogger.warning(f"Experiment API request failed: {e}") + return JSONResponse( + status_code=502, content={"error": "Experiment API is unavailable."} + ) + + # SSRF protections for the image proxy # ------------------------------------ # The image proxy fetches a fully user-controlled URL (student Skulpt image diff --git a/bases/rsptx/interactives/runestone/common/js/pretext-iframe-resize.js b/bases/rsptx/interactives/runestone/common/js/pretext-iframe-resize.js new file mode 100644 index 000000000..6ccc8f8c6 --- /dev/null +++ b/bases/rsptx/interactives/runestone/common/js/pretext-iframe-resize.js @@ -0,0 +1,59 @@ +// Used for experimental custom runestone components implemented inside +// iframes. This script should automatically request that the iframe get +// resized to fit the content and update when content does. + +// It will not install if the page is not inside an iframe, or if the page does not have a +// wrapper element with the attribute data-pretext-frame-resize="lti.frameResize". +// The wrapper element should be the outermost element of the interactive's -if.html page. + +// PreTeXt's parent page loads lti_iframe_resizer.js. It identifies the +// sending iframe by contentWindow and expects a height in pixels. +function installPretextIframeResize() { + if (window.parent === window) return; + + // PreTeXt marks the content wrapper on an interactive's -if.html page. + const wrapper = document.querySelector( + '[data-pretext-frame-resize="lti.frameResize"]', + ); + if (!wrapper) return; + // The generated aspect ratio reserves space before the component renders. + // Once JS runs, use the component's actual height instead. + wrapper.style.aspectRatio = "auto"; + + let pending = false; + let lastHeight = -1; + const reportHeight = () => { + pending = false; + const height = Math.ceil( + wrapper.getBoundingClientRect().bottom + window.scrollY, + ); + if (height === lastHeight) return; + lastHeight = height; + window.parent.postMessage( + { subject: "lti.frameResize", height }, + window.location.origin === "null" ? "*" : window.location.origin, + ); + }; + const scheduleReport = () => { + if (pending) return; + pending = true; + window.requestAnimationFrame(reportHeight); + }; + + // Runestone renders asynchronously, and later actions may change height. + // Observe content rather than the viewport that the parent resizes. + if (window.ResizeObserver) { + new ResizeObserver(scheduleReport).observe(wrapper, { + box: "border-box", + }); + } + scheduleReport(); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", installPretextIframeResize, { + once: true, + }); +} else { + installPretextIframeResize(); +} diff --git a/bases/rsptx/interactives/webpack.index.js b/bases/rsptx/interactives/webpack.index.js index 48267256d..d6cbcf378 100644 --- a/bases/rsptx/interactives/webpack.index.js +++ b/bases/rsptx/interactives/webpack.index.js @@ -32,6 +32,7 @@ import "./runestone/common/css/material-symbols.css"; import "./runestone/common/js/bookfuncs.js"; import "./runestone/common/js/user-highlights.js"; import "./runestone/common/js/pretext.js"; +import "./runestone/common/js/pretext-iframe-resize.js"; // These are only needed for the Runestone book, but not in a library mode (such as pretext). I would prefer to dynamically load them. However, these scripts are so small I haven't bothered to do so. import { getSwitch, switchTheme } from "./runestone/common/js/theme.js"; diff --git a/test/bases/rsptx/book_server_api/test_rsproxy.py b/test/bases/rsptx/book_server_api/test_rsproxy.py index e6bff3f8a..480bed35e 100644 --- a/test/bases/rsptx/book_server_api/test_rsproxy.py +++ b/test/bases/rsptx/book_server_api/test_rsproxy.py @@ -235,6 +235,75 @@ async def test_imageproxy_relays_upstream_404(): assert "not found" in resp.text.lower() +# --------------------------------------------------------------------------- +# Allowlisted experiment API tunnel +# --------------------------------------------------------------------------- + + +async def test_experiment_api_fetches_only_allowlisted_target(): + def handler(request): + assert str(request.url) == "https://rdaas.flypages.dev/api/duck?color=green" + assert "cookie" not in request.headers + assert "authorization" not in request.headers + assert "x-api-key" not in request.headers + return httpx.Response( + 200, json={"duck": "quack"}, headers={"Content-Type": "application/json"} + ) + + install_mock("experiment", handler) + async with book_client() as client: + resp = await client.get( + "/rsproxy/experiments/rdaas/api/duck?color=green", + headers={"Cookie": "student_session=private", "X-API-KEY": "private"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"duck": "quack"} + assert resp.headers["cache-control"] == "no-store" + + +async def test_experiment_api_rejects_unknown_target(): + async with book_client() as client: + resp = await client.get("/rsproxy/experiments/rdaas/api/other") + assert resp.status_code == 404 + + +async def test_experiment_api_relays_upstream_error(): + install_mock("experiment", lambda request: httpx.Response(503, text="Unavailable")) + async with book_client() as client: + resp = await client.get("/rsproxy/experiments/rdaas/api/duck") + assert resp.status_code == 503 + assert resp.text == "Unavailable" + + +async def test_experiment_api_refuses_redirect(): + install_mock( + "experiment", + lambda request: httpx.Response(302, headers={"Location": "http://127.0.0.1/"}), + follow_redirects=False, + ) + async with book_client() as client: + resp = await client.get("/rsproxy/experiments/rdaas/api/duck") + assert resp.status_code == 502 + + +async def test_experiment_api_caps_response(monkeypatch): + monkeypatch.setattr(rsproxy, "MAX_EXPERIMENT_RESPONSE_BYTES", 10) + install_mock("experiment", lambda request: httpx.Response(200, content=b"x" * 11)) + async with book_client() as client: + resp = await client.get("/rsproxy/experiments/rdaas/api/duck") + assert resp.status_code == 502 + + +async def test_experiment_api_maps_timeout(): + def handler(request): + raise httpx.ReadTimeout("too slow", request=request) + + install_mock("experiment", handler) + async with book_client() as client: + resp = await client.get("/rsproxy/experiments/rdaas/api/duck") + assert resp.status_code == 504 + + # --------------------------------------------------------------------------- # jobe # ---------------------------------------------------------------------------