fix private share links: https redirect via LOCAL_DEV-keyed scheme, NODE_ENV at build, and form-safe referrer policy
21 unmodified lines2223242526272627282930313229 unmodified lines21 unmodified lines
# Build frontend (CSS + JS bundles)RUN cd apps/main-app && bun run build
# Build compiled serverRUN bun build \# Build compiled server (NODE_ENV must be set here: bun inlines process.env.NODE_ENV# reads at build time, and without it they freeze as non-production in the binary)RUN NODE_ENV=production bun build \ --compile \ --target bun \ --minify \ --outfile server \29 unmodified lines18 unmodified lines192021222323242526278 unmodified lines18 unmodified lines# Install dependenciesRUN bun install --frozen-lockfile
# Compile to a single binaryRUN bun build --compile --minify apps/firehose-service/src/index.ts --outfile /app/firehoseRUN NODE_ENV=production bun build --compile --minify apps/firehose-service/src/index.ts --outfile /app/firehose
# Stage 2: Minimal runtimeFROM alpine:3.22
8 unmodified lines18 unmodified lines1920212223232425262717 unmodified lines18 unmodified lines# Install dependenciesRUN bun install --frozen-lockfile
# Compile to a single binaryRUN bun build --compile --minify apps/hosting-service/src/index.ts --outfile /app/hostingRUN NODE_ENV=production bun build --compile --minify apps/hosting-service/src/index.ts --outfile /app/hosting
# Stage 2: Minimal runtimeFROM alpine:3.22
17 unmodified lines228 unmodified linesExpand all22923023123223323423523623723823924024124224324424523323423523623723823924024124224324424524624724824927 unmodified lines228 unmodified linesExpand all }) contexts.push(bobContext) const bobPage = await bobContext.newPage() await login(bobPage, bob) const redeem = await bobContext.request.post(`${appUrl}/private/redeem`, { form: { siteId: site.siteId, token: new URL(share.data.directUrl).searchParams.get('wisp_share') || '', }, headers: { Origin: appUrl }, maxRedirects: 0, }) const grant = redeem.headers().location if (redeem.status() !== 303 || !grant) throw new Error(`audience redeem failed: ${redeem.status()} ${await redeem.text()}`) await bobPage.goto(grant, { waitUntil: 'domcontentloaded' }) if (!(await bobPage.getByText('permissioned data, in practice').count())) // Submit the gate's form from the browser rather than via the API client: the // cross-origin POST only carries a usable Origin header when the gate page's // referrer policy allows one, and the main app's CSRF check rejects `null`. await bobPage.goto(share.data.directUrl, { waitUntil: 'domcontentloaded' }) await bobPage.getByRole('button', { name: /sign in with atproto/i }).click() await bobPage.waitForLoadState('domcontentloaded') try { await bobPage.getByText('permissioned data, in practice').first().waitFor({ timeout: 15_000 }) } catch { throw new Error( `audience account could not open site: ${bobPage.url()} ${await bobPage.locator('body').innerText()}`, ) } await screenshot(bobPage, '05-audience-site-open')
const revoke = await json(ownerPage, `/api/user/private-sites/${site.siteId}/shares/${share.data.shareId}`, { method: 'DELETE',27 unmodified lines14 unmodified lines15161718192021222346 unmodified lines70717273747576757778798081197 unmodified linesExpand all14 unmodified lines PRIVATE_ACCESS_PAGE_STYLES, PRIVATE_GRANT_QUERY_PARAM, PRIVATE_SESSION_TTL_MINUTES, parseCookieHeader, privateFormResponseHeaders, privateResponseHeaders, sessionCookieName,} from '@wispplace/private-sites'import { lookup } from 'mime-types'46 unmodified lines <button class="private-action" type="submit">sign in with atproto <span aria-hidden="true">→</span></button></form></main></body></html>`
// This page POSTs cross-origin to the main app, so it cannot send `no-referrer`. return new Response(body, { status: 200, headers: { ...privateResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, headers: { ...privateFormResponseHeaders(), 'Content-Type': 'text/html; charset=utf-8' }, })}const anonymousSignInPage = (siteId: string): Response => { // Keep this independent of database state so hostnames cannot be probed for existence.197 unmodified linesExpand all91 unmodified lines9293949596969798991001011011021031041054 unmodified lines1101111121131141151161171181191201211221231241251261271281297 unmodified lines91 unmodified linesdescribe('csrfProtection', () => { const privateOrigin = 'https://amber-anchor-fox-1234.priv.wisp.place' const protect = csrfProtection(['.priv.wisp.place'])
const run = (path: string) => { const run = (path: string, origin: string = privateOrigin) => { const set: { status?: number | string } = {} const result = protect({ request: new Request(`https://wisp.place${path}`, { method: 'POST', headers: { Host: 'wisp.place', Origin: privateOrigin }, headers: { Host: 'wisp.place', Origin: origin }, }), set, }) return { result, set }4 unmodified lines expect(result).toBeUndefined() expect(set.status).toBeUndefined() })
// Browsers serialize the origin of a non-CORS POST as `null` when the posting // document sends `Referrer-Policy: no-referrer`, so the private sign-in page // must not use that policy — see privateFormResponseHeaders(). test('rejects opaque null origins on scoped share redemption', () => { const { result, set } = run('/private/redeem', 'null') 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 lines5 unmodified lines67891011101112131415165 unmodified lines5 unmodified lines
const BASE_HOST = (process.env.BASE_HOST || process.env.BASE_DOMAIN || 'wisp.place').split(':')[0] || 'wisp.place'export const privateHost = (): string => process.env.PRIVATE_HOST || `priv.${BASE_HOST}`
const scheme = (): 'http' | 'https' => process.env.LOCAL_DEV === 'true' || process.env.NODE_ENV !== 'production' ? 'http' : 'https'// process.env.NODE_ENV is inlined by `bun build --compile` at image build time, so the// scheme must key off LOCAL_DEV (a runtime-only read) to stay https in production binaries.const scheme = (): 'http' | 'https' => (process.env.LOCAL_DEV === 'true' ? 'http' : 'https')export const privateSiteUrl = (siteId: string): string => buildPrivateSiteUrl(siteId, privateHost(), scheme())export const privateOwnerUrl = (siteId: string, handoff: string): string => privateGrantUrlFor(privateSiteUrl(siteId), handoff)export const privateShareUrl = (siteId: string, token: string): string =>5 unmodified lines28 unmodified lines29303132333435363713 unmodified lines28 unmodified lines generateRecordId, generateSiteId, isValidSiteId, PRIVATE_STORAGE_PREFIX, privateFormResponseHeaders, privateResponseHeaders,} from './site-id'export type { GeneratedShareToken } from './token'export {13 unmodified lines3 unmodified lines45678910111233 unmodified lines464748495051525354555657585960616216 unmodified lines3 unmodified lines buildPrivateStorageKey, generateRecordId, generateSiteId, isValidSiteId, privateFormResponseHeaders, privateResponseHeaders,} from './site-id'import { generateShareToken } from './token'
33 unmodified lines expect(headers['Cache-Control']).toContain('no-store') expect(headers['Referrer-Policy']).toBe('no-referrer') expect(headers['X-Robots-Tag']).toContain('noindex') })
it('keeps a usable origin on pages that post cross-origin forms', () => { const headers = privateFormResponseHeaders() // `no-referrer` makes browsers send `Origin: null` on non-CORS POSTs, // which the main app's CSRF check rejects. expect(headers['Referrer-Policy']).toBe('strict-origin') expect(headers['Cache-Control']).toContain('no-store') expect(headers['X-Robots-Tag']).toContain('noindex') })})
describe('share token hygiene', () => { it('uses short, hashed, unique tokens', () => {16 unmodified lines37 unmodified lines383940414243444546474849505152535437 unmodified lines Pragma: 'no-cache', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow, noarchive, nosnippet',})
/** * Headers for private pages that submit a cross-origin form to the main app. * * `no-referrer` cannot be used here: per Fetch, a non-CORS POST from a * `no-referrer` document serializes its origin as the literal `null`, which * fails the main app's Origin/Host CSRF check. `strict-origin` still withholds * the path (and any share token in it) while sending a usable Origin. */export const privateFormResponseHeaders = (): Record<string, string> => ({ ...privateResponseHeaders(), 'Referrer-Policy': 'strict-origin',})