Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions bases/rsptx/book_server_api/routers/rsproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()):
Expand All @@ -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"},
Comment thread
ascholerChemeketa marked this conversation as resolved.
Resolved
)
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
1 change: 1 addition & 0 deletions bases/rsptx/interactives/webpack.index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
69 changes: 69 additions & 0 deletions test/bases/rsptx/book_server_api/test_rsproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +243 to +261


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
# ---------------------------------------------------------------------------
Expand Down
Loading