diff --git a/cli/src/build-template.ts b/cli/src/build-template.ts
index 0b6cee9..8ccebdf 100644
--- a/cli/src/build-template.ts
+++ b/cli/src/build-template.ts
@@ -8,10 +8,38 @@ const root = fileURLToPath(new URL("../..", import.meta.url))
const srcDir = fileURLToPath(new URL("../../src", import.meta.url))
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
+// Generated static templates inline their scripts/styles, so script/style must
+// allow 'unsafe-inline' (and 'unsafe-eval' for ajv's runtime schema compilation).
+// The hardening that still applies: object-src/base-uri/frame-ancestors and
+// restricting where scripts/images may load from.
+const TEMPLATE_CSP = [
+ "default-src 'self'",
+ "connect-src * data: blob: ws: wss:",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "style-src 'self' 'unsafe-inline'",
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "frame-ancestors 'none'",
+].join("; ")
+
+function templateCspPlugin() {
+ return {
+ name: "apilot-template-csp",
+ transformIndexHtml(html: string) {
+ return html.replace(
+ "",
+ `\n `,
+ )
+ },
+ }
+}
+
const sharedConfig: InlineConfig = {
configFile: false,
root,
- plugins: [react(), tailwindcss()],
+ plugins: [react(), tailwindcss(), templateCspPlugin()],
resolve: {
alias: {
"@": srcDir,
diff --git a/src/App.tsx b/src/App.tsx
index 877b9ee..4dba1a8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -106,7 +106,6 @@ function AppContent() {
useSettings({
setAuthType: auth.setAuthType,
- setAuthToken: auth.setAuthToken,
}, loadFromUrl)
const isEmbedded = isEmbeddedMode()
diff --git a/src/components/channels/ChannelTestTab.tsx b/src/components/channels/ChannelTestTab.tsx
index eadaf0f..0eccd09 100644
--- a/src/components/channels/ChannelTestTab.tsx
+++ b/src/components/channels/ChannelTestTab.tsx
@@ -51,6 +51,8 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) {
}
url = url.replace(/\/$/, "") + address
if (authToken) {
+ // Browsers can't set custom headers on WebSocket, so the token must go in the
+ // query string. It's masked in the URL preview to avoid shoulder-surfing.
url += (url.includes("?") ? "&" : "?") + `token=${encodeURIComponent(authToken)}`
}
return url
@@ -147,7 +149,7 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) {
{/* URL preview + connect/disconnect */}
- {buildUrl() || "ws://..."}
+ {buildUrl().replace(/(token=)[^&]+/, "$1***") || "ws://..."}
{ws.status === "disconnected" || ws.status === "error" ? (