If you’re building a Rust Playground-style app, your threat model is ugly in all the fun ways.

You’re taking user input, compiling or executing code, rendering output, maybe embedding an editor, maybe shipping WebAssembly, and probably sprinkling in some analytics or third-party UI bits because product people always want one more script. That’s exactly the kind of app where Content Security Policy earns its keep.

A Rust Playground is not just “another website.” It’s a code execution surface with a browser front end. Even if your actual Rust execution happens in a locked-down backend container, your frontend is still exposed to XSS, supply-chain script injection, data exfiltration, and clickjacking. CSP helps reduce that blast radius.

I’m going to show how I’d build a practical CSP for a Rust Playground, what to avoid, and how to wire it into a Rust web app.

What a Rust Playground needs from CSP

A typical Rust Playground frontend might include:

  • Your own app shell
  • Monaco or CodeMirror editor assets
  • A compile/run API on your own backend
  • WebSocket or SSE for streaming logs
  • WebAssembly assets if you run formatting or analysis in-browser
  • Maybe analytics, consent tooling, or feature flags

That means your CSP has to allow exactly the resources you use, and nothing else.

At minimum, I’d think about these directives:

  • default-src
  • script-src
  • style-src
  • connect-src
  • img-src
  • font-src
  • frame-ancestors
  • base-uri
  • form-action
  • object-src

If you want a refresher on what each directive does, csp-guide.com is a good reference without drowning you in nonsense.

Start with a strict baseline

For a self-hosted Rust Playground, this is a sane starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

A few opinions here:

  • object-src 'none' should be basically non-negotiable.
  • frame-ancestors 'none' is great for playgrounds unless you intentionally support embedding.
  • base-uri 'self' blocks base tag abuse. Cheap win.
  • script-src with a nonce and strict-dynamic is far better than maintaining giant host allowlists.
  • style-src 'self' is ideal, but some frontend libraries still push you toward inline styles. Fight that if you can.

Real-world CSPs are messier

Once you add analytics, consent tooling, or external APIs, your policy grows. Here’s a real CSP header from headertest.com:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NTA2ZTFiZTgtYTQ3Mi00MGM3LThhNjUtNTg2NGZlMDg3MzFj' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

That’s a good example of a CSP that stayed pretty disciplined while still supporting third parties. If you want to inspect your own headers and spot weak points, I like HeaderTest because it shows the actual security headers cleanly.

The lesson: your playground CSP should start strict, then expand only for real dependencies.

A practical CSP for Rust Playground

Here’s a more realistic policy for a playground app with:

  • self-hosted JS bundles
  • a nonce for a tiny bootstrapping inline script
  • a compile API
  • WebSocket log streaming
  • optional WebAssembly
  • no third-party embeds
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' 'wasm-unsafe-eval';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://play.example.com wss://play.example.com;
  worker-src 'self' blob:;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Why wasm-unsafe-eval?

If your frontend uses WebAssembly in ways that trigger CSP restrictions, you may need it. Some WASM toolchains and browser behaviors still make this annoying. Don’t add it blindly, but don’t be surprised if your in-browser formatter or analyzer breaks without it.

Why worker-src?

Editors and language tooling often use Web Workers. Monaco definitely can. If your playground editor spawns workers from blob: URLs, you’ll need:

worker-src 'self' blob:;

That’s one of those directives people forget until the console starts screaming.

Generating a nonce in Rust

If you render even a tiny inline boot script, use a per-request nonce. Don’t hardcode one. Don’t reuse one across requests.

Here’s a simple Axum example.

Axum middleware for CSP nonce

use axum::{
    extract::Request,
    http::{HeaderValue, Response},
    middleware::Next,
    response::IntoResponse,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use rand::RngCore;

#[derive(Clone, Debug)]
pub struct CspNonce(pub String);

pub async fn csp_middleware(mut req: Request, next: Next) -> impl IntoResponse {
    let mut bytes = [0u8; 16];
    rand::thread_rng().fill_bytes(&mut bytes);
    let nonce = STANDARD.encode(bytes);

    req.extensions_mut().insert(CspNonce(nonce.clone()));

    let mut response: Response = next.run(req).await;

    let csp = format!(
        "default-src 'self'; \
         script-src 'self' 'nonce-{}' 'strict-dynamic'; \
         style-src 'self'; \
         img-src 'self' data:; \
         font-src 'self'; \
         connect-src 'self' https://play.example.com wss://play.example.com; \
         worker-src 'self' blob:; \
         frame-ancestors 'none'; \
         base-uri 'self'; \
         form-action 'self'; \
         object-src 'none'",
        nonce
    );

    response.headers_mut().insert(
        "Content-Security-Policy",
        HeaderValue::from_str(&csp).unwrap(),
    );

    response
}

Then add it to your router:

use axum::{middleware, routing::get, Router};

let app = Router::new()
    .route("/", get(index))
    .layer(middleware::from_fn(csp_middleware));

Using the nonce in HTML

If you’re rendering templates, pass the nonce into the view:

use axum::{extract::Extension, response::Html};

async fn index(Extension(nonce): Extension<CspNonce>) -> Html<String> {
    let html = format!(r#"<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Rust Playground</title>
  <script nonce="{nonce}">
    window.APP_CONFIG = {{
      apiBase: "/api"
    }};
  </script>
  <script src="/static/app.js" defer></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>"#);

    Html(html)
}

That inline script will run. Random injected inline script won’t.

Rocket example

If you’re on Rocket, fair enough. Same idea.

#[macro_use] extern crate rocket;

use rocket::fairing::{Fairing, Info, Kind};
use rocket::http::Header;
use rocket::{Request, Response};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use rand::RngCore;

pub struct CspFairing;

#[rocket::async_trait]
impl Fairing for CspFairing {
    fn info(&self) -> Info {
        Info {
            name: "CSP Headers",
            kind: Kind::Response,
        }
    }

    async fn on_response<'r>(&self, _req: &'r Request<'_>, res: &mut Response<'r>) {
        let mut bytes = [0u8; 16];
        rand::thread_rng().fill_bytes(&mut bytes);
        let nonce = STANDARD.encode(bytes);

        let csp = format!(
            "default-src 'self'; \
             script-src 'self' 'nonce-{}' 'strict-dynamic'; \
             style-src 'self'; \
             img-src 'self' data:; \
             font-src 'self'; \
             connect-src 'self' wss://play.example.com; \
             frame-ancestors 'none'; \
             base-uri 'self'; \
             form-action 'self'; \
             object-src 'none'",
            nonce
        );

        res.set_header(Header::new("Content-Security-Policy", csp));
    }
}

#[get("/")]
fn index() -> &'static str {
    "Rust Playground"
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .attach(CspFairing)
        .mount("/", routes![index])
}

For real template rendering, you’d want to generate the nonce earlier and inject it into the request context so the HTML and header match. The fairing-only version is enough to show the shape, but not enough for nonce-backed inline scripts.

Common CSP mistakes in playground apps

1. Falling back to unsafe-inline

This is the classic surrender move.

script-src 'self' 'unsafe-inline';

Don’t do that unless you genuinely have no path out. Nonces are better. Hashes are better. Refactoring is better.

2. Allowing every CDN you’ve ever touched

I see policies like this all the time:

script-src 'self' cdnjs.cloudflare.com unpkg.com jsdelivr.net *.google.com *;

That’s not a policy. That’s a wishlist.

For a Rust Playground, self-host your frontend assets when possible. The fewer remote script origins you trust, the better.

3. Forgetting WebSockets

If your compile status, logs, or terminal output stream over WebSockets, you need wss: or explicit WebSocket origins in connect-src.

connect-src 'self' https://play.example.com wss://play.example.com;

Without that, your app “mostly works” except for the part users actually care about.

4. Ignoring workers and WASM

Modern browser tooling loves workers. Browser-based language features love WASM. If your editor breaks only in production, CSP is often why.

Roll out safely with report-only

When tuning a CSP on an existing playground, start with report-only mode:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  connect-src 'self' wss://play.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

This lets you see what would break before you actually block it.

If you support CSP reporting endpoints, great. If not, at least watch the browser console and network failures during real user flows:

  • load editor
  • type code
  • compile
  • run
  • stream output
  • open docs/help modals
  • load any embedded examples

That’s where the surprises show up.

If I were shipping one today, I’d start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' 'wasm-unsafe-eval';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://play.example.com wss://play.example.com;
  worker-src 'self' blob:;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Then I’d add exceptions only when the app proves it needs them.

That’s the whole game with CSP: start narrow, measure breakage, and resist the temptation to paper over problems with unsafe-inline and giant allowlists. For a Rust Playground, that discipline matters more than usual, because you’re already operating in a higher-risk environment. Your frontend shouldn’t make it worse.