blog post + csrf fixes
62 unmodified lines6364656667686970717273747576777831 unmodified lines62 unmodified lines author: authors.nekomimi, tags: ['meta'], draft: true, }, { slug: 'private-sites', title: 'New and future: private sites, permissioned data, and open web realities.', description: 'The annocunement of private sites and detailing upcoming quota systems when permissioned data lands', date: '2026-08-11', author: authors.nekomimi, tags: ['announcement'], }]
/** True in `astro build`, false in `astro dev`. */const isProduction = import.meta.env.PROD31 unmodified lines375 unmodified linesExpand all37637737837938038138238338438538638738838939039139239339435 unmodified lines43043143243343443543643743843944011 unmodified lines45245345445545645745845946046146246346446546646744244346846947047147247347447547647747845245347948048148248348417 unmodified lines502503504505479506507508509510102 unmodified linesExpand all6136146156165906176186196206211 unmodified line62362462562660060162762862963063197 unmodified lines375 unmodified linesExpand all }}
/** * Return the subset of `WISP_OAUTH_SCOPE` not present in a token's granted scope. * * Reused both after a fresh OAuth callback (to warn) and at session restore (to * transparently re-auth when a stored token predates an expanded scope set). */function missingScopesFor(grantedScope: string | undefined): string[] { const granted = new Set((grantedScope || '').split(/\s+/).filter(Boolean)) return WISP_OAUTH_SCOPE.split(' ').filter((s) => !granted.has(decodeURIComponent(s)))}
/** * Authenticate with AT Protocol using OAuth loopback flow */export async function authenticateOAuth( handle?: string,35 unmodified lines let oauthPort = parsePort(kvGet(kv, dirOAuthPortKey)) ?? LOOPBACK_PORT let redirectUri = `http://${LOOPBACK_HOST}:${oauthPort}/oauth/callback` let client = createOAuthClient(redirectUri) const storedDid = kvGet(kv, dirKey) // Identifier to pass to `client.authorize`. Falls back to the stored DID so a // transparent re-auth (missing scopes) never prompts the user for a handle. let loginIdentifier = handle if (storedDid && options.forceReauth) { kv.del(dirKey) } else if (storedDid) { let canRestore = true11 unmodified lines if (canRestore) { try { const session = await client.restore(storedDid) if (session) { // A stored token may predate an expanded scope set (e.g. the // privateSite/domain.verify scopes). Re-auth transparently so the // subsequent XRPC call doesn't fail with a scope error. const tokenInfo = await session.getTokenInfo(false) const missingScopes = missingScopesFor(tokenInfo.scope) if (missingScopes.length > 0) { emitStatus(options, `Stored session is missing ${missingScopes.length} scope(s). Re-authenticating...`) kv.del(dirKey) canRestore = false // Re-auth for the same account without re-prompting. loginIdentifier = storedDid } else { emitStatus(options, `Restored session for ${storedDid}`) return { agent: new Agent(session), did: storedDid } emitStatus(options, `Restored session for ${storedDid}`) return { agent: new Agent(session), did: storedDid } } } } catch { // Session invalid or expired — clear mapping and re-auth kv.del(dirKey) } } }
// Need a handle to start a new OAuth flow if (!handle) { // Need an identifier to start a new OAuth flow if (!loginIdentifier) { throw new Error('No active session for this directory. Run `wispctl login <handle>` first.') }
if (!useKeychain) {17 unmodified lines client = createOAuthClient(redirectUri) }
// Start new OAuth flow emitStatus(options, `Starting OAuth flow for ${handle}...`) emitStatus(options, `Starting OAuth flow for ${loginIdentifier}...`)
const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => { const app = new Hono() let serverHandle: { close: () => void } | null = null102 unmodified linesExpand all ;(timeoutHandle as { unref: () => void }).unref() } })
const authUrl = await client.authorize(handle, { scope: WISP_OAUTH_SCOPE }) const authUrl = await client.authorize(loginIdentifier, { scope: WISP_OAUTH_SCOPE })
emitStatus(options, 'Opening browser for authentication...') emitStatus(options, `If browser does not open, visit: ${authUrl}`) await open(authUrl.toString())1 unmodified line const { params } = await callbackPromise const { session } = await client.callback(params)
const tokenInfo = await session.getTokenInfo(false) const grantedScopes = new Set((tokenInfo.scope || '').split(/\s+/).filter(Boolean)) const missingScopes = WISP_OAUTH_SCOPE.split(' ').filter((s) => !grantedScopes.has(decodeURIComponent(s))) const missingScopes = missingScopesFor(tokenInfo.scope) if (missingScopes.length > 0) { emitWarning( options, `OAuth token is missing ${missingScopes.length} requested scope(s). First missing: ${missingScopes[0]}`,97 unmodified lines14 unmodified lines15161718192021222324252627282944 unmodified lines14 unmodified lines 'place.wisp.v2.domain.claimSubdomain', 'place.wisp.v2.domain.delete', 'place.wisp.v2.domain.getList', 'place.wisp.v2.domain.getStatus', 'place.wisp.v2.domain.verify', 'place.wisp.v2.privateSite.create', 'place.wisp.v2.privateSite.createShare', 'place.wisp.v2.privateSite.delete', 'place.wisp.v2.privateSite.list', 'place.wisp.v2.privateSite.listShares', 'place.wisp.v2.privateSite.revokeShare', 'place.wisp.v2.site.delete', 'place.wisp.v2.site.getList',] as const
44 unmodified lines63 unmodified lines646566676869707168697071727374757652 unmodified lines63 unmodified lines {/* Applied before first paint so a stored theme never flashes the other one. */} <script is:inline> try { const stored = localStorage.getItem('wisp-theme'); if (stored === 'dark' || stored === 'light') { document.documentElement.dataset.theme = stored; } } catch {} document.documentElement.dataset.theme = stored === 'light' || stored === 'dark' ? stored : 'dark'; } catch { document.documentElement.dataset.theme = 'dark'; } </script> </head>
<body>52 unmodified lines2 unmodified lines345678910111189 unmodified linesExpand all2 unmodified lines * * Same aesthetic as the docs site: * Clean, flat, keyboard-first · sharp corners · tight spacing * JetBrains Mono throughout · warm-beige light / slate-violet dark * warm-beige light / slate-violet dark (dark by default) * * Tokens are deliberately the same values as docs/src/styles/custom.css and * apps/main-app/public/wisp.css so all three properties read as one product. */1189 unmodified linesExpand all121 unmodified linesExpand all1221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621637 unmodified lines121 unmodified linesExpand all message: 'Request origin does not match host', }) })
// A PDS relays `agent.call(...)` to /xrpc server-to-server, so those requests carry // no Origin header. XRPC is bearer-authenticated and never reads the session cookie, // so requiring an origin there only breaks the CLI. test('allows originless XRPC procedures', () => { for (const path of ['/xrpc/place.wisp.v2.privateSite.create', '/xrpc/place.wisp.v2.domain.claim', '/xrpc']) { const set: { status?: number | string } = {} const result = protect({ request: new Request(`https://wisp.place${path}`, { method: 'POST', headers: { Host: 'wisp.place', Authorization: 'Bearer service-jwt' }, }), set, }) expect(result).toBeUndefined() expect(set.status).toBeUndefined() } })
test('still rejects originless cookie-authenticated routes', () => { const set: { status?: number | string } = {} const result = protect({ request: new Request('https://wisp.place/wisp/upload-files', { method: 'POST', headers: { Host: 'wisp.place' }, }), set, }) expect(set.status).toBe(403) expect(result).toEqual({ error: 'CSRF validation failed', message: 'Request origin does not match host', }) })
test('rejects private-site origins for other unsafe routes', () => { for (const path of ['/wisp/upload-files', '/api/auth/logout', '/api/domain/claim']) { const { result, set } = run(path) expect(set.status).toBe(403)7 unmodified lines41 unmodified lines424344454647484950515216 unmodified lines69707172737475767778798081828384858614 unmodified lines41 unmodified lines * `allowedExtraOrigins` permits additional hosts only for /private/redeem. * Entries beginning with `.` match any subdomain of that suffix, so * `<siteId>.priv.<host>` is covered without exempting any other route. * * `/xrpc/*` is exempt entirely: it is bearer-token authenticated, not cookie * authenticated. * * Usage: * ```ts * import { csrfProtection } from './lib/csrf' *16 unmodified lines const originHeader = request.headers.get('Origin') // Use X-Forwarded-Host if behind a proxy, otherwise use Host const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host') const path = new URL(request.url).pathname
// XRPC routes authenticate only via `Authorization: Bearer <service JWT>` and never // read the session cookie, so a cross-site POST carries no ambient authority to abuse. // An origin check there only breaks legitimate non-browser clients: a PDS relays // `agent.call(...)` server-to-server with no Origin header, which 403'd every CLI // procedure (private-site deploy, domain claim, site delete). if (path === '/xrpc' || path.startsWith('/xrpc/')) { return }
const extraHosts = path === '/private/redeem' ? allowedExtraOrigins : []
// Extra origins are trusted only for the scoped-share redemption endpoint. if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, ...extraHosts])) {14 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226---import Callout from "../../components/Callout.astro";import CodeBlock from "../../components/CodeBlock.astro";import Terminal from "../../components/Terminal.astro";import Post from "../../layouts/Post.astro";
const newPostExample = `---import Post from '../../layouts/Post.astro';---
<Post slug="my-post"> <p>Write whatever HTML you want in here.</p></Post>`;
const registryExample = `{ slug: 'my-post', title: 'A title that fits on one line', description: 'One or two sentences. Shown on the index and in RSS.', date: '2026-08-14', author: authors.nekomimi, tags: ['release'], draft: true,}`;
const scopedStyleExample = `<div class="chart">…</div>
<style> /* Scoped to this page only — Astro rewrites the selector. */ .chart { border: 1px solid var(--border); padding: 1rem; }</style>
<script> document.querySelector('.chart')?.classList.add('ready');</script>`;---
<Post slug="private-sites"> <p> wisp will be almost a year old soon. there are over 500 websites across 300 DIDs, and even became a cute home for many computer girls alongside humans. thank you for this as i never imagined that people will happily use what i have created just for my sake of hosting stuff quickly. people praise how nice the DX is, agents and humans alike enjoy the CLI and the ease of being able to create wisp sites with their extant atproto tooling, and that i'm able to serve you all very sustainably because of generous donations. thank you. </p>
<h2>new: private sites</h2> <p> private sites have been released silently for a few days now and are quite cool. it's a feature ive wanted after having my coding agents write reports, visualizations, and other things as HTML to me rather than plain markdown, and then wanting to share these with other people. many people have made their own solutions for this problem, but i found them cumbersome and also clunky to share around, so thus for the same reason why i spawned wisp.place, private sites are here too. </p> <p>first though, what private sites are not.</p> <h2>they are not e2ee</h2> <p> private sites are access-controlled, not end-to-end encrypted. the host, wisp.place, or potentially you if you fully self-host, can see the contents of a private site. </p> <p> i am running this for a community, and honestly, i do not have the resources to deal with the 0.1% of bad actors who upload, you know, that. section 230 is also uniquely american, and infrastructure operators elsewhere do not necessarily have the same legal protections. </p> <p> if you want something that is zero knowledge and fully encrypted, my friend juliet has a nice piece of software called <a href="https://github.com/notjuliet/drop">drop</a >. it's nicely designed and really easy to self host. </p> <h2>they are not on your PDS</h2> <p> for the initial version, private-site files are uploaded directly to wisp through an XRPC route. they never touch your PDS and do not use permissioned spaces. perm spaces was not yet put into alpha release like they were just today when i was making this. </p> <p> this does make private sites more centralized than ordinary wisp sites. for a normal `place.wisp.fs site`, wisp is mostly a CDN and hosting layer. the site records and blobs live on your PDS. you can host them yourself with wispctl, and anyone else can write their own software to render and serve the same site. </p> <p> that is what we like to call the open web, and it is one of the things AT Protocol is known for. the first version of private sites does not have that property. wisp stores the authoritative files and controls their delivery. </p> <p> i do not love this compromise, but i would rather launch something useful, with limitations, than wait for every part of the ideal protocol to exist. </p> <h2>what private sites are</h2> <p> private sites will be an easy way to upload websites that only you can see. you will also be able to create share links for a small group, or authorize specific AT Protocol accounts to view a site. </p> <p> wispctl will be able to upload private sites directly to wisp.place. you will be able to list them, delete them, create and revoke share links, and manage access through both wispctl and the wisp dashboard. </p> <code> wisp private deploy --path ./report --name "architecture review" </code> <p> after running a command like this, you get a private site owned by your account. you can open it yourself, create a share link, restrict that link to a particular DID, revoke it later, or let it expire automatically. </p> <p> private sites use a dedicated private origin. share tokens are generated from cryptographically random bytes, displayed once, and stored only as hashes. links can expire, be revoked, or be restricted to a specific AT Protocol account. </p> <h2>future: permissioned data</h2> <p> AT Protocol is developing permissioned data which allows data to remain tied to a user and their AT Protocol identity without being published onto the global public firehose. the proposal introduces spaces. a user can participate in a space, write data into a permissioned repository on their own PDS, and authorize applications to read and work with the contents of that space. </p> <p> for private wisp sites, the intended future could look roughly like this: </p> <pre> <code>user and wisp.place create a space ↓site files live in the user's permissioned repository ↓wisp.place receives access to the space ↓wisp gathers, validates, caches, and serves the site </code> </pre> <p> this fits private sites very naturally as the files could once again be owned by the user rather than existing only inside wisp's private storage. wisp could remain the application that evaluates share links, account access, expiration, publication, and hosting, while the authoritative source belongs to the user's DID. </p> <p> i also currently intend to move public Wisp sites into spaces. a major reason is operational. spaces would give wisp a much clearer boundary for tracking storage, managing sites, and enforcing fair total quotas. right now, public wisp sites are scattered across ordinary public repositories and blobs. wisp can cache and serve them, but there is no clean per-user storage boundary that wisp controls. </p> <p> with spaces, wisp can treat each user's sites as managed data, measure what it is serving, reject over-quota state, and make the hosted service sustainable without changing the fact that users can replace wisp.place with their own wispctl deployment. </p> <h2>anonymous access to spaces</h2> <p> under the current permissioned-data design, a space is not anonymously world-readable. an authorized application can read it. a member can read it. wisp can read it and serve the result, but an anonymous person cannot simply discover a user's PDS, request the space records and blobs, and reconstruct the site. that is perfectly reasonable for a private site, but much less comfortable for a public one. </p> <p> a public website can still be visible to everyone through wisp, but its underlying records and blobs would only be available to the user, wisp, or another application authorized to access the space. the website would still be public, but the source would no longer be anonymously and independently replicable in the way ordinary public AT Protocol data is today. so while i currently intend to move public sites into spaces, i do not think the current read model is ideal. </p> <p> i hope there is more discussion around allowing spaces to opt into anonymous read access. a space that permits public reads while retaining controlled writes and space-based management would solve this neatly. </p> <h2>the other reality: quotas</h2> <p> i mentioned this already, but right now, wisp.place does not enforce a total storage quota across a user's public sites. moving sites into permissioned spaces will make it much easier to measure and enforce storage fairly, and i intend to do that. each user will receive: 300 MB total or 1 GB total for supporters. these limits apply to storage provided by wisp.place. they are not a protocol limitation, and they are not meant to prevent you from hosting larger sites yourself. </p> <p> if you do not like the quota, use wispctl. self-hosted wispctl can take the place of wisp.place in this architecture. you can run your own host, use your own storage, set your own limits, and serve as much as your infrastructure allows. that escape hatch matters to me. </p> <p> wisp is still something I operate partly out of my own pocket. i do not want to charge for it, and i do not want to turn it into a subscription hosting company. but free infrastructure without fair limits eventually becomes infrastructure subsidized for whoever is willing to consume the most. </p></Post>