proxy.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import { NextResponse } from 'next/server'
  2. import type { NextRequest } from 'next/server'
  3. import { isCrossSiteWrite } from '@/lib/same-origin'
  4. import { REQUEST_PATH_HEADER } from '@/lib/request-path'
  5. /**
  6. * Cross-origin access to the technician API, in development only.
  7. *
  8. * The app that consumes this API is native, and native clients are not subject
  9. * to the same-origin policy, so production needs no CORS headers at all and
  10. * deliberately sends none. The only thing they would enable there is a browser
  11. * on someone else's origin holding a workshop's bearer token.
  12. *
  13. * Development is different: Expo serves the app from localhost on its own port
  14. * while the workshop runs on another, so every request is cross-origin and the
  15. * browser blocks it before the app sees a response. From inside the app that
  16. * is indistinguishable from being offline, which sends people hunting for the
  17. * wrong bug.
  18. *
  19. * This is `proxy.ts`, not `middleware.ts`. Next 16 renamed the file, and on
  20. * 16.3.1 the old name still loads and runs but makes every matched API route
  21. * answer 404, whatever the handler returns. That failure looks like a missing
  22. * route rather than a misplaced file, so it is worth stating here: do not
  23. * rename this back.
  24. */
  25. const IS_DEV = process.env.NODE_ENV !== 'production'
  26. function applyCors(response: NextResponse, origin: string | null) {
  27. if (!IS_DEV) return response
  28. // Echoes the caller's origin rather than sending `*`, because a wildcard and
  29. // an `Authorization` header are not a combination worth normalising, even
  30. // in development.
  31. response.headers.set('Access-Control-Allow-Origin', origin ?? '*')
  32. response.headers.append('Vary', 'Origin')
  33. response.headers.set('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS')
  34. response.headers.set('Access-Control-Allow-Headers', 'Authorization,Content-Type,Accept,X-Org-Id')
  35. response.headers.set('Access-Control-Max-Age', '600')
  36. // Without this the browser hides the header from the app entirely, and
  37. // sign-in succeeds on the server while the client reports no token back.
  38. // Native clients see every header and never needed it.
  39. response.headers.set('Access-Control-Expose-Headers', 'set-auth-token')
  40. return response
  41. }
  42. const PROTECTED_API = '/api/protected/'
  43. /**
  44. * A page request carries the path it asked for, so a layout that sends a
  45. * visitor to sign-in can bring them back there (lib/sign-in-redirect.server.ts).
  46. * A layout has no other way to see its URL. Next's own `_rsc` marker is left
  47. * out, and anything a client sent under the same name is overwritten.
  48. */
  49. function withRequestPath(request: NextRequest) {
  50. const url = request.nextUrl.clone()
  51. url.searchParams.delete('_rsc')
  52. const requestHeaders = new Headers(request.headers)
  53. requestHeaders.set(REQUEST_PATH_HEADER, `${url.pathname}${url.search}`)
  54. return NextResponse.next({ request: { headers: requestHeaders } })
  55. }
  56. export default function proxy(request: NextRequest) {
  57. if (!request.nextUrl.pathname.startsWith('/api/')) return withRequestPath(request)
  58. // A write to the cookie-authenticated API from another site is refused
  59. // before any handler runs. See lib/same-origin.ts.
  60. if (request.nextUrl.pathname.startsWith(PROTECTED_API)) {
  61. if (isCrossSiteWrite({ method: request.method, header: (n) => request.headers.get(n) })) {
  62. return NextResponse.json({ error: 'Cross-site request refused' }, { status: 403 })
  63. }
  64. return undefined
  65. }
  66. if (!IS_DEV) return undefined
  67. const origin = request.headers.get('origin')
  68. // Preflight. The app sends Authorization and X-Org-Id, both of which put
  69. // every request past the "simple request" bar and into a preflight.
  70. if (request.method === 'OPTIONS') {
  71. return applyCors(new NextResponse(null, { status: 204 }), origin)
  72. }
  73. return applyCors(NextResponse.next(), origin)
  74. }
  75. export const config = {
  76. matcher: [
  77. // Everything under the technician API except the WebSocket. An upgrade
  78. // handshake is not an ordinary response, and putting CORS headers on one
  79. // is at best pointless and at worst breaks it.
  80. '/api/v1/tech/((?!ws$).*)',
  81. // Sign-in lives here, so the app hits it before it has a session and
  82. // would otherwise fail its very first request from a browser.
  83. '/api/public/auth/:path*',
  84. // The cookie-authenticated API: cross-site writes are refused here.
  85. '/api/protected/:path*',
  86. // Pages, for the requested path above. Not the API, Next's own files, or
  87. // anything with a file extension (icons, images, the manifest).
  88. '/((?!api/|_next/|.*\\.[^/]+$).*)',
  89. ],
  90. }