Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-mcp/src/page.rs

//! **The consent page**: who is asking, where the code goes, the separate
//! write grant (unticked), and one passkey touch. Its script is a file of its
//! own (`/oauth/authorize.js`) so the console's CSP (`script-src 'self'`)
//! needs no new hash.

use axum::response::{IntoResponse, Response};

/// Escape text for HTML.
pub(crate) fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            c => out.push(c),
        }
    }
    out
}

const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:34rem;margin:3rem auto;padding:0 1rem;color:#222;background:#fafafa}\
main{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1.5rem}\
h1{font-size:1.3rem}code{background:#f0f0f0;padding:0 .2rem}\
button{font-size:1rem;padding:.5rem 1rem;margin-right:.5rem}\
.err{color:#a00}.ok{color:#070}label{display:block;margin:1rem 0}";

/// The consent page for one waiting request.
pub(crate) fn consent(request: &str, client: &str, to: &str, offers_write: bool) -> String {
    let write = if offers_write {
        "<label><input type=\"checkbox\" id=\"write\"> Also allow it to <strong>write</strong> (comment on issues as you). Leave this off unless you need it.</label>"
    } else {
        "<input type=\"hidden\" id=\"write\" value=\"\">"
    };
    format!(
        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>Gunnar: connect an app</title><style>{STYLE}</style></head><body>\
<main id=\"consent\" data-request=\"{request}\">\
<h1>Connect {client} to Gunnar</h1>\
<p><strong>{client}</strong> asks to read the repositories you can see on this Gunnar, as you: repositories, files, commits, diffs, code search, issues and merge requests.</p>\
<p>It gets no shell, no network and no other address. It can never see more than you can.</p>\
{write}\
<p>After your passkey, you go back to <code>{to}</code>. The connection lasts at most a day, and you can end it at any time.</p>\
<p><button id=\"approve\" type=\"button\">Allow with my passkey</button><button id=\"deny\" type=\"button\">Deny</button></p>\
<p id=\"msg\" role=\"status\"></p></main>\
<script src=\"/oauth/authorize.js\"></script></body></html>",
        request = escape(request),
        client = escape(client),
        to = escape(to),
    )
}

/// A page that sends nobody anywhere: the request was not good enough to
/// redirect.
pub(crate) fn refusal(why: &str) -> String {
    format!(
        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Gunnar: cannot connect</title><style>{STYLE}</style></head>\
<body><main><h1>This app cannot connect</h1><p class=\"err\">{}</p></main></body></html>",
        escape(why)
    )
}

const SCRIPT: &str = r#"'use strict';
const main = document.getElementById('consent');
const request = main.dataset.request;
function show(text, kind) { const m = document.getElementById('msg'); m.textContent = text; m.className = kind || ''; }
function b64urlToBuf(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s), bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; }
function bufToB64url(buf) { const bytes = new Uint8Array(buf); let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function said(e) {
  if (e instanceof TypeError) return 'Gunnar did not answer. Check your connection and try again.';
  if (typeof DOMException !== 'undefined' && e instanceof DOMException) return e.name === 'NotAllowedError' ? 'The passkey request was cancelled or timed out. Try again.' : 'Something went wrong with the passkey. Try again.';
  return (e && e.message) || 'Something went wrong. Try again.';
}
async function post(path, body) {
  const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  if (!res.ok) throw new Error((await res.text()) || 'refused');
  return res.json();
}
async function approve() {
  const btn = document.getElementById('approve'); btn.disabled = true;
  try {
    show('Waiting for your passkey …');
    const start = await post('/oauth/authorize/passkey', { request });
    const pk = start.publicKey;
    pk.challenge = b64urlToBuf(pk.challenge);
    pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
    const cred = await navigator.credentials.get({ publicKey: pk });
    const w = document.getElementById('write');
    const done = await post('/oauth/authorize/approve', { request, write: !!(w && w.checked), ceremony_id: start.ceremony_id, id: cred.id, response: {
      clientDataJSON: bufToB64url(cred.response.clientDataJSON),
      authenticatorData: bufToB64url(cred.response.authenticatorData),
      signature: bufToB64url(cred.response.signature) } });
    show('Connected. Returning to the app …', 'ok');
    location.assign(done.redirect);
  } catch (e) { show(said(e), 'err'); btn.disabled = false; }
}
async function deny() {
  try { const done = await post('/oauth/authorize/deny', { request }); show('Denied. The app gets nothing.', 'ok'); location.assign(done.redirect); }
  catch (e) { show(said(e), 'err'); }
}
document.getElementById('approve').addEventListener('click', approve);
document.getElementById('deny').addEventListener('click', deny);
"#;

/// `GET /oauth/authorize.js`.
pub(crate) async fn script() -> Response {
    (
        [
            (http::header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
            (http::header::CACHE_CONTROL, "no-cache"),
        ],
        SCRIPT,
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_page_escapes_what_a_client_registered() {
        let page = consent(
            "r1",
            "<img src=x onerror=alert(1)>",
            "https://claude.ai/api/mcp/auth_callback",
            false,
        );
        assert!(!page.contains("<img"), "{page}");
        assert!(page.contains("&lt;img"));
        assert!(
            !page.contains("type=\"checkbox\""),
            "no write box unless write was asked for"
        );
        assert!(
            consent("r1", "Claude", "x", true).contains("type=\"checkbox\" id=\"write\">"),
            "the write box is offered unticked"
        );
        assert!(
            !page.contains("<script>"),
            "no inline script: the CSP allows only 'self'"
        );
    }
}