Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"trust:verify": "node --experimental-strip-types scripts/verify-trust-attestation.ts",
"package:verify": "node --experimental-strip-types scripts/verify-package-signature.ts",
"test": "npm run test:plain && npm run test:ts",
"test:security-headers": "node --experimental-strip-types --test tests/response-security.test.ts",
"test:plain": "node --experimental-strip-types --test tests/adversarial-harness.test.ts tests/trust.test.ts tests/release-signing.test.ts tests/cli-install.test.ts tests/trust-attestation.test.ts",
"test:ts": "node --experimental-strip-types --test tests/site-stats.test.ts tests/certify.test.ts tests/prompt-injection-guard.test.ts tests/audit-skills.test.ts tests/runtime.test.ts tests/integrations.test.ts tests/growth-revenue-split.test.ts tests/trust-badge.test.ts tests/bounties.test.ts tests/trust-scoring.test.ts tests/adversarial-holdout.test.ts tests/adversarial-judge.test.ts tests/paired-benchmark.test.ts tests/format-rubric.test.ts tests/lang-detect.test.ts tests/university.test.ts tests/package-signature.test.ts",
"test:all": "node --experimental-strip-types --test tests/*.test.ts",
Expand Down
5 changes: 5 additions & 0 deletions public/.well-known/security.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Contact: mailto:contact@zeroagency.ai
Expires: 2027-04-28T00:00:00Z
Canonical: https://superagentskill.com/.well-known/security.txt
Preferred-Languages: en, pt-BR
Policy: https://github.com/criptogus/agent-evolve-network/blob/main/SECURITY.md
4 changes: 4 additions & 0 deletions src/components/site/JsonLd.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useRouter } from "@tanstack/react-router";

interface JsonLdProps {
data: Record<string, unknown> | Array<Record<string, unknown>>;
}

/** Renders a JSON-LD <script> tag. Safe to use on the client and during SSR. */
export function JsonLd({ data }: JsonLdProps) {
const router = useRouter();
return (
<script
nonce={router.options.ssr?.nonce}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\u003c") }}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/csp-context.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";

const storage = new AsyncLocalStorage<string>();
export const withCspNonce = <T>(nonce: string, run: () => T): T => storage.run(nonce, run);
export const requestCspNonce = () => storage.getStore();
2 changes: 1 addition & 1 deletion src/lib/error-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function renderErrorPage(): string {
<h1>This page didn't load</h1>
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
<div class="actions">
<button class="primary" onclick="location.reload()">Try again</button>
<a class="primary" href="">Try again</a>
<a class="secondary" href="/">Go home</a>
</div>
</div>
Expand Down
38 changes: 38 additions & 0 deletions src/lib/response-security.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** Keep browser sources scoped to integrations used by this app (not server-side APIs). */
export function responseSecurity(response: Response, nonce: string, url: string): Response {
const headers = new Headers(response.headers);
const preview = new URL(url).hostname.endsWith(".lovable.app");
headers.set("X-Frame-Options", preview ? "SAMEORIGIN" : "DENY");
if (headers.get("content-type")?.includes("text/html")) {
headers.set(
"Content-Security-Policy",
[
"default-src 'self'",
// /~flock.js and /~api/analytics are same-origin Lovable proxies.
`script-src 'self' 'nonce-${nonce}' https://js.stripe.com https://challenges.cloudflare.com`,
"script-src-attr 'none'",
// React/Radix and charts use inline styles; scripts remain nonce-protected.
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"connect-src 'self' https://ynxuotbjfxkcrfvkrltr.supabase.co wss://ynxuotbjfxkcrfvkrltr.supabase.co https://api.stripe.com https://r.stripe.com https://m.stripe.network https://challenges.cloudflare.com https://auth.lovable.dev https://oauth.lovable.app",
"frame-src https://js.stripe.com https://hooks.stripe.com https://checkout.stripe.com https://challenges.cloudflare.com https://auth.lovable.dev https://oauth.lovable.app",
preview
? "frame-ancestors 'self' https://lovable.dev https://*.lovable.dev"
: "frame-ancestors 'none'",
"worker-src 'self' blob:",
"base-uri 'self'",
"object-src 'none'",
"form-action 'self'",
"upgrade-insecure-requests",
].join("; "),
);
// Never reuse a document's per-request nonce via a shared HTML cache.
headers.set("Cache-Control", "no-store");
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
22 changes: 22 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome'
import { Route as WhitepaperRouteImport } from './routes/whitepaper'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known.oauth-authorization-server'
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known.oauth-protected-resource'
import { Route as DotwellKnownSecurityDottxtRouteImport } from './routes/[.]well-known.security[.]txt'
import { Route as AccountAgentsRouteImport } from './routes/account.agents'
import { Route as AccountBillingRouteImport } from './routes/account.billing'
import { Route as AccountCloudSkillsRouteImport } from './routes/account.cloud-skills'
Expand Down Expand Up @@ -432,6 +433,12 @@ const DotwellKnownOauthProtectedResourceRoute =
path: '/.well-known/oauth-protected-resource',
getParentRoute: () => rootRouteImport,
} as any)
const DotwellKnownSecurityDottxtRoute =
DotwellKnownSecurityDottxtRouteImport.update({
id: '/.well-known/security.txt',
path: '/.well-known/security.txt',
getParentRoute: () => rootRouteImport,
} as any)
const AccountAgentsRoute = AccountAgentsRouteImport.update({
id: '/account/agents',
path: '/account/agents',
Expand Down Expand Up @@ -1168,6 +1175,7 @@ export interface FileRoutesByFullPath {
'/whitepaper': typeof WhitepaperRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/security.txt': typeof DotwellKnownSecurityDottxtRoute
'/account/agents': typeof AccountAgentsRoute
'/account/billing': typeof AccountBillingRoute
'/account/cloud-skills': typeof AccountCloudSkillsRoute
Expand Down Expand Up @@ -1349,6 +1357,7 @@ export interface FileRoutesByTo {
'/whitepaper': typeof WhitepaperRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/security.txt': typeof DotwellKnownSecurityDottxtRoute
'/account/agents': typeof AccountAgentsRoute
'/account/billing': typeof AccountBillingRoute
'/account/cloud-skills': typeof AccountCloudSkillsRoute
Expand Down Expand Up @@ -1532,6 +1541,7 @@ export interface FileRoutesById {
'/whitepaper': typeof WhitepaperRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
'/.well-known/security.txt': typeof DotwellKnownSecurityDottxtRoute
'/account/agents': typeof AccountAgentsRoute
'/account/billing': typeof AccountBillingRoute
'/account/cloud-skills': typeof AccountCloudSkillsRoute
Expand Down Expand Up @@ -1716,6 +1726,7 @@ export interface FileRouteTypes {
| '/whitepaper'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/security.txt'
| '/account/agents'
| '/account/billing'
| '/account/cloud-skills'
Expand Down Expand Up @@ -1897,6 +1908,7 @@ export interface FileRouteTypes {
| '/whitepaper'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/security.txt'
| '/account/agents'
| '/account/billing'
| '/account/cloud-skills'
Expand Down Expand Up @@ -2079,6 +2091,7 @@ export interface FileRouteTypes {
| '/whitepaper'
| '/.well-known/oauth-authorization-server'
| '/.well-known/oauth-protected-resource'
| '/.well-known/security.txt'
| '/account/agents'
| '/account/billing'
| '/account/cloud-skills'
Expand Down Expand Up @@ -2262,6 +2275,7 @@ export interface RootRouteChildren {
WhitepaperRoute: typeof WhitepaperRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren
DotwellKnownSecurityDottxtRoute: typeof DotwellKnownSecurityDottxtRoute
AccountAgentsRoute: typeof AccountAgentsRoute
AccountBillingRoute: typeof AccountBillingRoute
AccountCloudSkillsRoute: typeof AccountCloudSkillsRoute
Expand Down Expand Up @@ -2696,6 +2710,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport
parentRoute: typeof rootRouteImport
}
'/.well-known/security.txt': {
id: '/.well-known/security.txt'
path: '/.well-known/security.txt'
fullPath: '/.well-known/security.txt'
preLoaderRoute: typeof DotwellKnownSecurityDottxtRouteImport
parentRoute: typeof rootRouteImport
}
'/account/agents': {
id: '/account/agents'
path: '/account/agents'
Expand Down Expand Up @@ -3879,6 +3900,7 @@ const rootRouteChildren: RootRouteChildren = {
DotwellKnownOauthAuthorizationServerRouteWithChildren,
DotwellKnownOauthProtectedResourceRoute:
DotwellKnownOauthProtectedResourceRouteWithChildren,
DotwellKnownSecurityDottxtRoute: DotwellKnownSecurityDottxtRoute,
AccountAgentsRoute: AccountAgentsRoute,
AccountBillingRoute: AccountBillingRoute,
AccountCloudSkillsRoute: AccountCloudSkillsRoute,
Expand Down
11 changes: 10 additions & 1 deletion src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export const getRouter = () => {
export const getRouter = async () => {
const queryClient = new QueryClient();

const router = createRouter({
Expand All @@ -12,5 +12,14 @@ export const getRouter = () => {
defaultPreloadStaleTime: 0,
});

if (import.meta.env.SSR) {
const { requestCspNonce } = await import("./lib/csp-context.server");
router.options.ssr = { ...router.options.ssr, nonce: requestCspNonce() };
} else {
router.options.ssr = {
...router.options.ssr,
nonce: document.querySelector<HTMLScriptElement>("script[nonce]")?.nonce,
};
}
return router;
};
18 changes: 18 additions & 0 deletions src/routes/[.]well-known.security[.]txt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import securityTxt from "../../public/.well-known/security.txt?raw";

// Explicit route as well as the public asset: dot directories may bypass static serving.
export const Route = createFileRoute("/.well-known/security.txt")({
server: {
handlers: {
GET: () =>
new Response(securityTxt, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
}),
},
},
});
13 changes: 12 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import "./lib/error-capture";
import { withCspNonce } from "./lib/csp-context.server";
import { responseSecurity } from "./lib/response-security.server";

import { consumeLastCapturedError } from "./lib/error-capture";
import { renderErrorPage } from "./lib/error-page";
Expand Down Expand Up @@ -100,7 +102,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | "__timeout
]);
}

export default {
const server = {
async fetch(request: Request, env: unknown, ctx: unknown) {
try {
const redirect = canonicalRedirect(request);
Expand Down Expand Up @@ -130,3 +132,12 @@ export default {
}
},
};

export default {
async fetch(request: Request, env: unknown, ctx: unknown) {
const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(24))));
return withCspNonce(nonce, async () =>
responseSecurity(await server.fetch(request, env, ctx), nonce, request.url),
);
},
};
87 changes: 87 additions & 0 deletions tests/response-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { responseSecurity } from "../src/lib/response-security.server.ts";
import { withCspNonce, requestCspNonce } from "../src/lib/csp-context.server.ts";

const html = () =>
new Response("<html></html>", {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8", "Retry-After": "5" },
});

test("HTML gets nonce CSP, denies framing, and cannot be cached", async () => {
const response = responseSecurity(html(), "test-nonce", "https://superagentskill.com/");
const csp = response.headers.get("Content-Security-Policy")!;
assert.match(csp, /script-src 'self' 'nonce-test-nonce'/);
assert.match(csp, /script-src-attr 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /https:\/\/js.stripe.com/);
assert.match(csp, /wss:\/\/ynxuotbjfxkcrfvkrltr.supabase.co/);
assert.doesNotMatch(
csp.split(";").find((d) => d.trim().startsWith("script-src "))!,
/unsafe-inline|unsafe-eval/,
);
assert.equal(response.headers.get("X-Frame-Options"), "DENY");
assert.equal(response.headers.get("Cache-Control"), "no-store");
assert.equal(response.status, 503);
assert.equal(response.headers.get("Retry-After"), "5");
assert.equal(await response.text(), "<html></html>");
});

test("only actual Lovable previews allow the editor's framing origins", () => {
for (const host of [
"preview.lovable.app",
"superagentskill.com",
"preview.lovable.app.evil.test",
]) {
const response = responseSecurity(html(), "nonce", `https://${host}/`);
const preview = host === "preview.lovable.app";
assert.equal(response.headers.get("X-Frame-Options"), preview ? "SAMEORIGIN" : "DENY");
assert.equal(
response.headers.get("Content-Security-Policy")!.includes("https://*.lovable.dev"),
preview,
);
}
});

test("JSON, redirects, and empty responses retain their semantics", async () => {
for (const response of [
Response.json({ ok: true }),
Response.redirect("https://superagentskill.com"),
new Response(null, { status: 204 }),
]) {
const expectedBody = await response.clone().text();
const secured = responseSecurity(response, "nonce", "https://superagentskill.com/");
assert.equal(secured.headers.get("Content-Security-Policy"), null);
assert.equal(secured.status, response.status);
assert.equal(secured.headers.get("X-Frame-Options"), "DENY");
assert.equal(await secured.text(), expectedBody);
}
});

test("concurrent async request contexts keep their nonce isolated", async () => {
assert.equal(requestCspNonce(), undefined);
await Promise.all(
["first", "second"].map((nonce) =>
withCspNonce(nonce, async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(requestCspNonce(), nonce);
}),
),
);
assert.equal(requestCspNonce(), undefined);
});

test("disclosure document has the published contact and a bounded expiry", async () => {
const body = await readFile(
new URL("../public/.well-known/security.txt", import.meta.url),
"utf8",
);
assert.match(body, /^Contact: mailto:contact@zeroagency.ai$/m);
assert.match(body, /^Canonical: https:\/\/superagentskill.com\/\.well-known\/security.txt$/m);
assert.match(body, /^Preferred-Languages: en, pt-BR$/m);
const expires = Date.parse(body.match(/^Expires: (.+)$/m)![1]);
assert.ok(expires > Date.now());
assert.ok(expires < Date.now() + 365 * 24 * 60 * 60 * 1000);
});
Loading