Security checklist
Astrolabe is built so that answers do not leak by accident: views carry no answers, ids are opaque, results carry no key data. These are the parts only the host can get right. The list comes from the project's security review; each item says why it matters.
Answer keys
Section titled “Answer keys”- Never send
result.keyto the browser. The key holds every accepted answer. Store it next to the view on your server and grade there. Only the HTML-with-answers mode puts answers in the page, by design. - Keep keys out of public folders.
astl compilewrites the view and metadata to--outand the key to--key-outwith mode0600, and refuses a--key-outinside--out. Do the same in your own pipeline. - Store views and keys together. A key belongs to one
viewHash; grade a view only with its own key.
Secrets and salts
Section titled “Secrets and salts”- A real secret, from your secret store. At least 16 bytes (
openssl rand -base64 32), read from the environment (ASTL_SECRET), never committed. With a known secret anyone can undo the keyed option order and map opaque ids back to source ids. - Never fall back to a fixed secret. If the secret is missing, fail, or use a random one for that process
only, as
examples/node-serverdoes (its views then change on every restart). - Keep it stable. The secret decides every view: rotating it changes every opaque id and
viewHash. - A salt per exercise, for example the exercise id: two exercises then never share ids.
astl compilerequires a non-empty--salt. - Throw-away secrets in the browser. The authoring kit's preview and the HTML-with-answers bundle compile in the
browser with public secrets (
'editor-preview-only-key','astrolabe-in-browser-key'). That is fine there, because the answers are in the page anyway; never use your production secret in browser code.
Attempts
Section titled “Attempts”- The host decides which attempt a request is for. Authorise the request (does this student own attempt
123?) and pass that attempt to the handler:grading(request, { attemptId }). The handler reads only the action (checkorsubmit, the final path segment) from the URL, so extra path segments cannot point it at another attempt. Ids must match[A-Za-z0-9_-]{1,128}; anything else is404. - The policy comes from the assignment, never from the request. If a query parameter or a form field could
pick
practice, a student could check their test answers. (examples/node-serverhas a?mode=switch for the demo only.) - Atomic writes. Implement
recordCheckandrecordSubmitof yourAttemptStoreatomically per attempt (for exampleUPDATE ... RETURNING, or an update conditional onsubmitted = false), so parallel requests can neither exceedmaxChecksnor submit twice. - Deadlines on the server. The player's countdown is a display. The handler closes checks at the deadline and
accepts a submit up to 30 seconds after it (
DEADLINE_GRACE_MS), because the player hands in when its countdown ends. - Rate-limit
check. Practice policies may allow unlimited checks; limit requests per student in front of the handler. Groups scoredall-correctnever say which part is wrong, so checks cannot probe single slots. - Log failures, return nothing. Pass
onError(error, request)tocreateGradingHandler: failing stores and invalid records end in a generic500 internal_errorwithout details.
Views and the player
Section titled “Views and the player”-
Set
integrity.<astl-player src integrity>refuses a view whose SHA-256 differs from theviewHash(an emptyintegrityrefuses every view). Serve views immutable under/v/{viewHash}.html. -
Serve views from a separate origin or with a strict CSP. Views contain no scripts, and the player rebuilds every view from an inert template, keeping only the content format's allow-list and the registered manifests' attributes and children. A strict policy is defence in depth.
-
A strict Content Security Policy. The bundles need no
eval, no inline scripts and no inline styles (the elements use constructable stylesheets, and the TipTap surface of<astl-editor>an adopted stylesheet). The end-to-end tests play an exercise withastrolabe.jsand withastrolabe-player.js, and author one withastrolabe-editor.js, under this policy; add your API origin toconnect-srcwhen the endpoint or the views live elsewhere:Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'self' -
Media need their sources named. A video or audio file plays from
media-src(and its poster is a picture,img-src): add your asset host, or'self'when the files are on the page's origin. A YouTube or Vimeo video loads its provider's player only when the student presses Play, in a frame: addframe-src https://www.youtube-nocookie.com https://player.vimeo.com. Such a video is an id, not an address, so the URL policy does not see it: leave the provider out offrame-srcand the browser blocks its player. Captions from another origin load with CORS: the asset host must sendAccess-Control-Allow-Originfor the video and its captions. -
Pin bundle versions. Load an exact version from a CDN, ideally with Subresource Integrity (
<script type="module" src="..." integrity="sha384-..." crossorigin="anonymous">), or serve the files yourself. -
Authenticated endpoints.
endpointsends requests with the browser's default credentials (same origin). For tokens, passhttpChecker({ endpoint, headers: { Authorization: ... } })as the player'schecker; for cross-origin cookies,credentials: 'include'and a matching CORS policy on your server.
Content
Section titled “Content”- Validate before you store. Run
engine.validate(source)(or compile) when an author saves; zero errors means the source compiles. Sources are limited before parsing: 1,000,000 characters (SOURCE_TOO_LARGE), 256 attributes on one element (SOURCE_TOO_COMPLEX) and 100 levels of nesting (SOURCE_TOO_DEEP), so no input makes the parser slow. - Third-party components. The engine checks every component's view against its manifest and fails the compile when a private value would reach the view. Review a definition before you register it all the same: it runs with your secret.
- Open answers are the student's own text: escape them where a teacher reads them, and in a CSV export put a
'before a value that starts with=,+,-or@, so no spreadsheet runs it as a formula.
URLs: links and pictures
Section titled “URLs: links and pictures”One UrlPolicy decides which addresses stay: links (a[href]), pictures (img[src]) and the url attributes of
components. Give the same policy to the engine, the player and the editor, or the player removes what the engine
kept:
import { isSafeUrl, type UrlPolicy } from 'astrolabe/engine';
// Everything that loads by itself (pictures, a component's media) from one asset host; links as by default.const urlPolicy: UrlPolicy = (url, { tag, attribute }) => tag === 'a' && attribute === 'href' ? isSafeUrl(url) : url.startsWith('https://assets.example.org/');
createEngine({ secret, urlPolicy }); // the server that compiles (astrolabe/server)definePlayer({ urlPolicy }); // every page with <astl-player> (astrolabe/player)defineEditor({ urlPolicy }); // the authoring page: issues, the surface and the preview (astrolabe/editor)-
The default,
isSafeUrl, keeps absolutehttp:,https:andmailto:addresses (tabs and line breaks ignored, as browsers do), so nojavascript:ordata:address reaches a student. -
One asset host (above) is the safest wider policy: a view cannot make students' browsers load pictures or media from anywhere else (no tracking pixels of a third party), and your CSP can name that host in
img-src. Only links, which load nothing until a student follows them, go elsewhere. -
The policy is asked with the element's tag and attribute:
imgandsrcfor a picture in text, the component's tag and its attribute for a component'surlattribute. It must answertrueto keep an address: a policy that throws, or answers anything else (anasyncfunction's Promise), blocks the address. -
Relative paths (
/assets/tree.svg) resolve against the page that plays the view, not the one the author wrote it on: allow them only when every page that plays your views serves the same paths, and only the paths you serve (never//host/..., which is another host):// Paths under /assets/ only, in plain segments: no `..`, which the browser would resolve to another path.const ASSET = /^\/assets\/(?:[\w-]+\/)*[\w-]+(?:\.[\w-]+)*$/;const urlPolicy: UrlPolicy = (url, { tag }) => isSafeUrl(url) || (tag === 'img' && ASSET.test(url)); -
Never allow
data:,blob:orjavascript:addresses: the policy is the only check an address gets. -
A page holds one player policy: the last
definePlayercall that passes one sets it, and the preview of<astl-editor>passes the editor's when it has one of its own (the default never replaces yours). Use one policy per page. -
The player sends no referrer for pictures (
referrerpolicy="no-referrer"), so an asset host never sees the page's address. An upload (uploadAsset) gets the file the author picked: check its type and size on your server, store it where students can load it, and serve SVG withX-Content-Type-Options: nosniff(or convert it). -
With a script-tag bundle, set it from your own module that imports the bundle, so it runs before the player reads the page:
import { createEngine, createSourceCompiler, definePlayer, IN_BROWSER_SECRET } from '.../astrolabe.js', thendefinePlayer({ urlPolicy, compileSource: createSourceCompiler(createEngine({ secret: IN_BROWSER_SECRET, urlPolicy })) }).
Reporting
Section titled “Reporting”Found a vulnerability? Do not open a public issue; see SECURITY.md in the repository.