Sfoglia il codice sorgente

Harden for the Intuit security review: validated redirects, sealed realm id, security headers, same-origin guard (#331)

Bernt Christian Egeland 3 settimane fa
parent
commit
3a17e102a2

+ 18 - 0
next.config.ts

@@ -14,6 +14,24 @@ const nextConfig: NextConfig = {
   },
   },
   async headers() {
   async headers() {
     return [
     return [
+      // Every response. Later, more specific entries win on the same key, so
+      // the status-report share below still gets its stricter values.
+      {
+        source: '/(.*)',
+        headers: [
+          { key: 'X-Content-Type-Options', value: 'nosniff' },
+          // Nothing off this origin may frame the app; the app frames its own
+          // previews and print views, so same-origin stays allowed.
+          { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
+          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+          // Camera and microphone are used by our own pages (scanning, photos,
+          // recordings); an embedded third-party frame gets neither.
+          {
+            key: 'Permissions-Policy',
+            value: 'camera=(self), microphone=(self), geolocation=(self)',
+          },
+        ],
+      },
       {
       {
         source: '/share/status-report/:path*',
         source: '/share/status-report/:path*',
         headers: [
         headers: [

+ 15 - 2
src/__tests__/features/integrations/quickbooks-server.test.ts

@@ -1,3 +1,4 @@
+import { realmRef } from '@/integrations/quickbooks/mapping'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import type {
 import type {
   AccountingCustomer,
   AccountingCustomer,
@@ -93,7 +94,7 @@ function makeCtx(input: {
       state,
       state,
       externalAccountId: '9130357',
       externalAccountId: '9130357',
     },
     },
-    credentials: { accessToken: 'tok', refreshToken: 'ref' },
+    credentials: { accessToken: 'tok', refreshToken: 'ref', realmId: '9130357' },
     http: {
     http: {
       fetch: async () => {
       fetch: async () => {
         throw new Error('not used')
         throw new Error('not used')
@@ -1025,7 +1026,8 @@ describe('QuickBooks: connecting', () => {
       },
       },
     })
     })
     const who = await connector.identify?.(t.ctx)
     const who = await connector.identify?.(t.ctx)
-    expect(who).toEqual({ id: '9130357', name: 'Sandbox Garage Ltd (sandbox)' })
+    expect(who).toEqual({ id: realmRef('9130357'), name: 'Sandbox Garage Ltd (sandbox)' })
+    expect(who?.id).not.toContain('9130357')
     expect(t.state).toMatchObject({
     expect(t.state).toMatchObject({
       environment: 'sandbox',
       environment: 'sandbox',
       country: 'GB',
       country: 'GB',
@@ -1040,6 +1042,17 @@ describe('QuickBooks: connecting', () => {
     expect(t.calls.at(-1)?.host).toBe('sandbox-quickbooks.api.intuit.com')
     expect(t.calls.at(-1)?.host).toBe('sandbox-quickbooks.api.intuit.com')
   })
   })
 
 
+  it('reads the company from the sealed credentials, falling back to older state', async () => {
+    const fresh = makeCtx({ state: { realmId: undefined }, answer: emptyCompany() })
+    await connector.jobs['accounting.invoice'](fresh.ctx, { entityId: 'svc1' })
+    expect(fresh.calls[0].path).toBe('/v3/company/9130357/query')
+
+    const legacy = makeCtx({ answer: emptyCompany() })
+    legacy.ctx.credentials = { accessToken: 'tok', refreshToken: 'ref' }
+    await connector.jobs['accounting.invoice'](legacy.ctx, { entityId: 'svc1' })
+    expect(legacy.calls[0].path).toBe('/v3/company/9130357/query')
+  })
+
   it('recognises automated sales tax and says when sales tax is off', async () => {
   it('recognises automated sales tax and says when sales tax is off', async () => {
     const t = makeCtx({
     const t = makeCtx({
       answer: (call) => {
       answer: (call) => {

+ 32 - 0
src/__tests__/lib/safe-redirect.test.ts

@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { safeRedirectPath } from '@/lib/safe-redirect'
+
+describe('safeRedirectPath', () => {
+  it('keeps a path on this site, query string and all', () => {
+    expect(safeRedirectPath('/settings/integrations/quickbooks')).toBe(
+      '/settings/integrations/quickbooks'
+    )
+    expect(safeRedirectPath('/vehicles?tab=open#top')).toBe('/vehicles?tab=open#top')
+    expect(safeRedirectPath('  /settings  ')).toBe('/settings')
+  })
+
+  it('falls back for anything that would leave the site', () => {
+    for (const bad of [
+      'https://evil.example/',
+      'http://evil.example',
+      '//evil.example/path',
+      '/\\evil.example',
+      'javascript:alert(1)',
+      'settings',
+      '',
+      '/line\nbreak',
+    ]) {
+      expect(safeRedirectPath(bad)).toBe('/')
+    }
+  })
+
+  it('falls back to the given default when there is nothing usable', () => {
+    expect(safeRedirectPath(null, '/dashboard')).toBe('/dashboard')
+    expect(safeRedirectPath(undefined)).toBe('/')
+  })
+})

+ 48 - 0
src/__tests__/lib/same-origin.test.ts

@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { isCrossSiteWrite } from '@/lib/same-origin'
+
+function req(method: string, headers: Record<string, string>) {
+  const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
+  return { method, header: (name: string) => lower[name.toLowerCase()] ?? null }
+}
+
+describe('isCrossSiteWrite', () => {
+  it('never blocks reads', () => {
+    expect(isCrossSiteWrite(req('GET', { origin: 'https://evil.example', host: 'app.x' }))).toBe(
+      false
+    )
+    expect(isCrossSiteWrite(req('HEAD', { 'sec-fetch-site': 'cross-site' }))).toBe(false)
+  })
+
+  it('trusts the browser when it says where the request came from', () => {
+    expect(isCrossSiteWrite(req('POST', { 'sec-fetch-site': 'cross-site' }))).toBe(true)
+    expect(isCrossSiteWrite(req('POST', { 'sec-fetch-site': 'same-origin' }))).toBe(false)
+    expect(isCrossSiteWrite(req('POST', { 'sec-fetch-site': 'same-site' }))).toBe(false)
+  })
+
+  it('compares Origin with the host the request was made to', () => {
+    const same = { origin: 'https://app.torqvoice.com', host: 'app.torqvoice.com' }
+    expect(isCrossSiteWrite(req('POST', same))).toBe(false)
+    expect(
+      isCrossSiteWrite(req('DELETE', { origin: 'https://evil.example', host: 'app.torqvoice.com' }))
+    ).toBe(true)
+    // Behind the proxy the public host arrives forwarded.
+    expect(
+      isCrossSiteWrite(
+        req('PATCH', {
+          origin: 'https://app.torqvoice.com',
+          host: 'torqvoice-app:3000',
+          'x-forwarded-host': 'app.torqvoice.com',
+        })
+      )
+    ).toBe(false)
+    expect(isCrossSiteWrite(req('POST', { origin: 'null', host: 'app.torqvoice.com' }))).toBe(true)
+    expect(isCrossSiteWrite(req('POST', { origin: 'not a url', host: 'app.torqvoice.com' }))).toBe(
+      true
+    )
+  })
+
+  it('lets requests without an Origin through, as native clients send none', () => {
+    expect(isCrossSiteWrite(req('POST', { host: 'app.torqvoice.com' }))).toBe(false)
+  })
+})

+ 4 - 5
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -1,6 +1,7 @@
 'use client'
 'use client'
 
 
 import { Suspense, useEffect, useRef, useState } from 'react'
 import { Suspense, useEffect, useRef, useState } from 'react'
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { useRouter, useSearchParams } from 'next/navigation'
 import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -50,8 +51,7 @@ function SignInFormInner({
         setPassword('')
         setPassword('')
         passwordRef.current?.focus()
         passwordRef.current?.focus()
       } else {
       } else {
-        const redirect = searchParams.get('redirect') || '/'
-        router.push(redirect)
+        router.push(safeRedirectPath(searchParams.get('redirect')))
         router.refresh()
         router.refresh()
       }
       }
     } catch {
     } catch {
@@ -100,8 +100,7 @@ function SignInFormInner({
         const msg = typeof result.error.message === 'string' ? result.error.message : ''
         const msg = typeof result.error.message === 'string' ? result.error.message : ''
         setError(msg || t('errors.passkeyFailed'))
         setError(msg || t('errors.passkeyFailed'))
       } else {
       } else {
-        const redirect = searchParams.get('redirect') || '/'
-        router.push(redirect)
+        router.push(safeRedirectPath(searchParams.get('redirect')))
         router.refresh()
         router.refresh()
       }
       }
     } catch {
     } catch {
@@ -234,7 +233,7 @@ function SignInFormInner({
         <p className="mt-6 text-center text-sm text-muted-foreground">
         <p className="mt-6 text-center text-sm text-muted-foreground">
           {t('noAccount')}{' '}
           {t('noAccount')}{' '}
           <Link
           <Link
-            href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(searchParams.get('redirect')!)}` : ''}`}
+            href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(safeRedirectPath(searchParams.get('redirect')))}` : ''}`}
             className="font-medium text-primary hover:underline"
             className="font-medium text-primary hover:underline"
           >
           >
             {t('createOne')}
             {t('createOne')}

+ 2 - 1
src/app/(public)/auth/sign-up/page.tsx

@@ -1,4 +1,5 @@
 import { redirect } from 'next/navigation'
 import { redirect } from 'next/navigation'
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { auth } from '@/lib/auth'
 import { auth } from '@/lib/auth'
@@ -14,7 +15,7 @@ export default async function SignUpPage({
 }) {
 }) {
   const params = await searchParams
   const params = await searchParams
   const inviteToken = params.invite
   const inviteToken = params.invite
-  const redirectTo = params.redirect
+  const redirectTo = params.redirect ? safeRedirectPath(params.redirect) : undefined
 
 
   // If already authenticated, redirect to the target or home
   // If already authenticated, redirect to the target or home
   const session = await auth.api.getSession({ headers: await headers() })
   const session = await auth.api.getSession({ headers: await headers() })

+ 2 - 1
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -1,5 +1,6 @@
 'use client'
 'use client'
 
 
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { useRef, useState } from 'react'
 import { useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
@@ -73,7 +74,7 @@ export function SignUpForm({
         const acceptResult = await acceptInvitation({ token: inviteToken })
         const acceptResult = await acceptInvitation({ token: inviteToken })
         if (acceptResult.success) {
         if (acceptResult.success) {
           // Invited users have a known email — skip verification, go straight to dashboard
           // Invited users have a known email — skip verification, go straight to dashboard
-          router.push(redirectTo || '/')
+          router.push(safeRedirectPath(redirectTo))
           router.refresh()
           router.refresh()
         } else {
         } else {
           setError(acceptResult.error || t('errors.invitationFailed'))
           setError(acceptResult.error || t('errors.invitationFailed'))

+ 2 - 1
src/app/(public)/onboarding/page.tsx

@@ -1,5 +1,6 @@
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import { redirect } from 'next/navigation'
 import { redirect } from 'next/navigation'
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { auth } from '@/lib/auth'
 import { auth } from '@/lib/auth'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { OnboardingForm } from '@/features/onboarding/Components/OnboardingForm'
 import { OnboardingForm } from '@/features/onboarding/Components/OnboardingForm'
@@ -10,7 +11,7 @@ export default async function OnboardingPage({
   searchParams: Promise<{ redirect?: string }>
   searchParams: Promise<{ redirect?: string }>
 }) {
 }) {
   const params = await searchParams
   const params = await searchParams
-  const redirectTo = params.redirect
+  const redirectTo = params.redirect ? safeRedirectPath(params.redirect) : undefined
   const session = await auth.api.getSession({ headers: await headers() })
   const session = await auth.api.getSession({ headers: await headers() })
 
 
   if (!session?.user?.id) {
   if (!session?.user?.id) {

+ 9 - 4
src/app/api/integrations/[connector]/oauth/callback/route.ts

@@ -80,24 +80,29 @@ export async function GET(
   }
   }
 
 
   // What the vendor tells us only here, such as which company was chosen,
   // What the vendor tells us only here, such as which company was chosen,
-  // goes on the state so identify and every job after it can read it.
+  // goes into the sealed credentials, next to the tokens, so identify and
+  // every job after it can read it and it never sits in the database in the
+  // clear: Intuit's rules put the realm id under the same encryption as the
+  // refresh token. A token refresh spreads the previous credentials, so it
+  // survives those.
   const extra: Record<string, string> = {}
   const extra: Record<string, string> = {}
   for (const name of spec.callbackParams ?? []) {
   for (const name of spec.callbackParams ?? []) {
     const value = params.get(name)
     const value = params.get(name)
     if (value) extra[name] = value
     if (value) extra[name] = value
   }
   }
-  const state0 = (connection.state as Record<string, unknown>) ?? {}
 
 
   await db.integrationConnection.update({
   await db.integrationConnection.update({
     where: { id: connection.id },
     where: { id: connection.id },
     data: {
     data: {
       oauthState: null,
       oauthState: null,
-      credentials: sealCredentials(credentials as unknown as Record<string, unknown>),
+      credentials: sealCredentials({
+        ...(credentials as unknown as Record<string, unknown>),
+        ...extra,
+      }),
       scopes: credentials.scope ?? spec.scopes.join(' '),
       scopes: credentials.scope ?? spec.scopes.join(' '),
       status: 'active',
       status: 'active',
       lastError: null,
       lastError: null,
       lastHealthAt: new Date(),
       lastHealthAt: new Date(),
-      ...(Object.keys(extra).length > 0 && { state: { ...state0, ...extra } as object }),
     },
     },
   })
   })
 
 

+ 5 - 3
src/features/integrations/Lib/types.ts

@@ -59,9 +59,11 @@ export type AuthSpec =
        */
        */
       tokenAuth?: 'body' | 'basic'
       tokenAuth?: 'body' | 'basic'
       /**
       /**
-       * Query parameters the vendor adds to the callback beside code and
-       * state, kept on the connection's state under the same names. Intuit
-       * sends the company id (realmId) this way and nowhere else.
+       * Query parameters the vendor adds to the callback besides code and
+       * state, kept inside the sealed credentials under the same names, next
+       * to the tokens, so they are encrypted at rest and survive a token
+       * refresh. Intuit sends the company id (realmId) this way and nowhere
+       * else.
        */
        */
       callbackParams?: string[]
       callbackParams?: string[]
       /** Environment variable names holding the platform-owned app's client id and secret. */
       /** Environment variable names holding the platform-owned app's client id and secret. */

+ 2 - 1
src/features/onboarding/Components/OnboardingForm.tsx

@@ -1,5 +1,6 @@
 'use client'
 'use client'
 
 
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { useState } from 'react'
 import { useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -34,7 +35,7 @@ export function OnboardingForm({ redirectTo }: { redirectTo?: string }) {
       if (!result.success) {
       if (!result.success) {
         modal.open('error', t('setupFailed'), result.error || t('couldNotCreate'))
         modal.open('error', t('setupFailed'), result.error || t('couldNotCreate'))
       } else {
       } else {
-        router.push(redirectTo || '/')
+        router.push(safeRedirectPath(redirectTo))
         router.refresh()
         router.refresh()
       }
       }
     } catch {
     } catch {

+ 6 - 0
src/integrations/quickbooks/mapping.ts

@@ -7,6 +7,7 @@
  * that the ledger arrived at the same total.
  * that the ledger arrived at the same total.
  */
  */
 
 
+import { createHash } from 'node:crypto'
 import type {
 import type {
   AccountingCustomer,
   AccountingCustomer,
   AccountingInvoice,
   AccountingInvoice,
@@ -33,6 +34,11 @@ export const NOTES_MAX = 2000
 
 
 export type Environment = 'production' | 'sandbox'
 export type Environment = 'production' | 'sandbox'
 
 
+/** A stable, non-reversible reference for a company id, for rows the browser sees. */
+export function realmRef(realm: string): string {
+  return `qbo-${createHash('sha256').update(realm).digest('hex').slice(0, 16)}`
+}
+
 export interface QboRef {
 export interface QboRef {
   value: string
   value: string
   name?: string
   name?: string

+ 15 - 2
src/integrations/quickbooks/server.ts

@@ -37,6 +37,7 @@ import {
   type QboPayment,
   type QboPayment,
   type QboTaxCode,
   type QboTaxCode,
   type QboTaxRate,
   type QboTaxRate,
+  realmRef,
   REVOKE_URL,
   REVOKE_URL,
   WALK_IN_NAME,
   WALK_IN_NAME,
   apiHost,
   apiHost,
@@ -83,6 +84,7 @@ class QboError extends Error {
 }
 }
 
 
 interface State {
 interface State {
+  /** Legacy: newer connections keep the realm in the sealed credentials. */
   realmId: string
   realmId: string
   environment: Environment
   environment: Environment
   country: string | null
   country: string | null
@@ -144,8 +146,14 @@ function settingsOf(ctx: ConnectorContext) {
   }
   }
 }
 }
 
 
+/**
+ * The company id. It arrives on the OAuth callback and is kept in the
+ * sealed credentials next to the tokens. Connections made before that was
+ * so still carry it on the state; they lose it the next time they connect.
+ */
 function realmOf(ctx: ConnectorContext): string {
 function realmOf(ctx: ConnectorContext): string {
-  const realm = stateOf(ctx).realmId
+  const sealed = ctx.credentials.realmId
+  const realm = typeof sealed === 'string' && sealed ? sealed : stateOf(ctx).realmId
   if (!realm) throw new Error('No QuickBooks company on this connection; reconnect')
   if (!realm) throw new Error('No QuickBooks company on this connection; reconnect')
   return realm
   return realm
 }
 }
@@ -934,7 +942,12 @@ export const connector: ConnectorServer = {
         'Custom transaction numbers are off in this QuickBooks company, so invoices there get QuickBooks numbers instead of the Torqvoice invoice numbers. Turn them on under Account and settings, Sales, Sales form content.'
         'Custom transaction numbers are off in this QuickBooks company, so invoices there get QuickBooks numbers instead of the Torqvoice invoice numbers. Turn them on under Account and settings, Sales, Sales form content.'
       )
       )
     }
     }
-    return { id: realm, name: env === 'sandbox' ? `${company.name} (sandbox)` : company.name }
+    // The realm is customer-identifying, so the row and the browser get a
+    // reference derived from it, enough to tell two companies apart.
+    return {
+      id: realmRef(realm),
+      name: env === 'sandbox' ? `${company.name} (sandbox)` : company.name,
+    }
   },
   },
   async test(ctx) {
   async test(ctx) {
     try {
     try {

+ 18 - 0
src/lib/safe-redirect.ts

@@ -0,0 +1,18 @@
+/**
+ * A redirect target taken from a query string, kept on this site.
+ *
+ * Sign-in, sign-up and onboarding carry the page a visitor was heading for in
+ * a `redirect` parameter and push it once they are through. Anything that is
+ * not a plain path on this origin is thrown away: an absolute URL would send
+ * the freshly signed-in person to someone else's site, and browsers read a
+ * leading `//` or `/\\` as protocol-relative, so those count as absolute too.
+ */
+export function safeRedirectPath(value: string | null | undefined, fallback = '/'): string {
+  if (typeof value !== 'string') return fallback
+  const path = value.trim()
+  if (!/^\/(?![/\\])/.test(path)) return fallback
+  // Control characters have no place in a path and are how header splitting starts.
+  // biome-ignore lint/suspicious/noControlCharactersInRegex: that is the point of the check
+  if (/[\u0000-\u001f\u007f]/.test(path)) return fallback
+  return path
+}

+ 31 - 0
src/lib/same-origin.ts

@@ -0,0 +1,31 @@
+/**
+ * Whether a state-changing request came from another site.
+ *
+ * The cookie-authenticated API under /api/protected is called by our own
+ * pages, so a write arriving from someone else's origin is never legitimate.
+ * SameSite=Lax cookies already keep a cross-site POST from carrying the
+ * session, so this is a second wall, and one a security scanner can see.
+ *
+ * Reads are left alone, as are requests that carry neither header: native
+ * clients and server-to-server callers send no Origin, and they are not the
+ * problem a cross-site request forgery poses.
+ */
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
+
+export function isCrossSiteWrite(input: {
+  method: string
+  header: (name: string) => string | null
+}): boolean {
+  if (SAFE_METHODS.has(input.method.toUpperCase())) return false
+  const site = input.header('sec-fetch-site')
+  if (site === 'cross-site') return true
+  const origin = input.header('origin')
+  if (!origin || origin === 'null') return origin === 'null'
+  const host = input.header('x-forwarded-host') ?? input.header('host')
+  if (!host) return false
+  try {
+    return new URL(origin).host.toLowerCase() !== host.split(',')[0].trim().toLowerCase()
+  } catch {
+    return true
+  }
+}

+ 14 - 0
src/proxy.ts

@@ -1,5 +1,6 @@
 import { NextResponse } from 'next/server'
 import { NextResponse } from 'next/server'
 import type { NextRequest } from 'next/server'
 import type { NextRequest } from 'next/server'
+import { isCrossSiteWrite } from '@/lib/same-origin'
 
 
 /**
 /**
  * Cross-origin access to the technician API, in development only.
  * Cross-origin access to the technician API, in development only.
@@ -42,7 +43,18 @@ function applyCors(response: NextResponse, origin: string | null) {
   return response
   return response
 }
 }
 
 
+const PROTECTED_API = '/api/protected/'
+
 export default function proxy(request: NextRequest) {
 export default function proxy(request: NextRequest) {
+  // A write to the cookie-authenticated API from another site is refused
+  // before any handler runs. See lib/same-origin.ts.
+  if (request.nextUrl.pathname.startsWith(PROTECTED_API)) {
+    if (isCrossSiteWrite({ method: request.method, header: (n) => request.headers.get(n) })) {
+      return NextResponse.json({ error: 'Cross-site request refused' }, { status: 403 })
+    }
+    return undefined
+  }
+
   if (!IS_DEV) return undefined
   if (!IS_DEV) return undefined
 
 
   const origin = request.headers.get('origin')
   const origin = request.headers.get('origin')
@@ -65,5 +77,7 @@ export const config = {
     // Sign-in lives here, so the app hits it before it has a session and
     // Sign-in lives here, so the app hits it before it has a session and
     // would otherwise fail its very first request from a browser.
     // would otherwise fail its very first request from a browser.
     '/api/public/auth/:path*',
     '/api/public/auth/:path*',
+    // The cookie-authenticated API: cross-site writes are refused here.
+    '/api/protected/:path*',
   ],
   ],
 }
 }