Skip to content

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.

  • Never send result.key to 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 compile writes the view and metadata to --out and the key to --key-out with mode 0600, and refuses a --key-out inside --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.
  • 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-server does (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 compile requires 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.
  • 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 (check or submit, 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 is 404.
  • 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-server has a ?mode= switch for the demo only.)
  • Atomic writes. Implement recordCheck and recordSubmit of your AttemptStore atomically per attempt (for example UPDATE ... RETURNING, or an update conditional on submitted = false), so parallel requests can neither exceed maxChecks nor 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 scored all-correct never say which part is wrong, so checks cannot probe single slots.
  • Log failures, return nothing. Pass onError(error, request) to createGradingHandler: failing stores and invalid records end in a generic 500 internal_error without details.
  • Set integrity. <astl-player src integrity> refuses a view whose SHA-256 differs from the viewHash (an empty integrity refuses 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 with astrolabe.js and with astrolabe-player.js, and author one with astrolabe-editor.js, under this policy; add your API origin to connect-src when 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: add frame-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 of frame-src and the browser blocks its player. Captions from another origin load with CORS: the asset host must send Access-Control-Allow-Origin for 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. endpoint sends requests with the browser's default credentials (same origin). For tokens, pass httpChecker({ endpoint, headers: { Authorization: ... } }) as the player's checker; for cross-origin cookies, credentials: 'include' and a matching CORS policy on your server.

  • 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.

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 absolute http:, https: and mailto: addresses (tabs and line breaks ignored, as browsers do), so no javascript: or data: 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: img and src for a picture in text, the component's tag and its attribute for a component's url attribute. It must answer true to keep an address: a policy that throws, or answers anything else (an async function'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: or javascript: addresses: the policy is the only check an address gets.

  • A page holds one player policy: the last definePlayer call 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 with X-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', then definePlayer({ urlPolicy, compileSource: createSourceCompiler(createEngine({ secret: IN_BROWSER_SECRET, urlPolicy })) }).

Found a vulnerability? Do not open a public issue; see SECURITY.md in the repository.