From 2e14cec7155b658a7fa311f8d9600748e0b581a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:46 +0000 Subject: [PATCH 1/6] Only trust allowlisted hosts in getDomainUrl Add an optional ALLOWED_HOSTS env var. The request host is used only if it's in ALLOWED_HOSTS, is the Fly.io app hostname, or is localhost; otherwise the canonical host is used. Co-authored-by: Kent C. Dodds --- .env.example | 5 + app/routes/_auth/forgot-password.test.ts | 127 +++++++++++++++++++++++ app/utils/env.server.ts | 5 + app/utils/misc.tsx | 38 ++++++- docs/decisions/048-allowed-hosts.md | 42 ++++++++ docs/deployment.md | 11 ++ docs/security.md | 20 ++++ 7 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 app/routes/_auth/forgot-password.test.ts create mode 100644 docs/decisions/048-allowed-hosts.md diff --git a/.env.example b/.env.example index c5daeb201..3d3517c52 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" GITHUB_TOKEN="MOCK_GITHUB_TOKEN" GITHUB_REDIRECT_URI="https://example.com/auth/github/callback" +# comma-separated hostnames your app is served from. The first one is the +# canonical host used for links in emails when a request's Host header isn't in +# this list. `${FLY_APP_NAME}.fly.dev` and localhost are always allowed. +# ALLOWED_HOSTS="example.com,www.example.com" + # set this to false to prevent search engines from indexing the website # default to allow indexing for seo safety ALLOW_INDEXING="true" diff --git a/app/routes/_auth/forgot-password.test.ts b/app/routes/_auth/forgot-password.test.ts new file mode 100644 index 000000000..761596218 --- /dev/null +++ b/app/routes/_auth/forgot-password.test.ts @@ -0,0 +1,127 @@ +import { invariant } from '@epic-web/invariant' +import { type AppLoadContext } from 'react-router' +import { afterEach, expect, test, vi } from 'vitest' +import { prisma } from '#app/utils/db.server.ts' +import { createUser } from '#tests/db-utils.ts' +import { requireEmail } from '#tests/mocks/utils.ts' +import { BASE_URL } from '#tests/utils.ts' +import { type Route } from './+types/forgot-password.ts' +import { action } from './forgot-password.tsx' + +const ROUTE_PATH = '/forgot-password' +const ACTION_ARGS_BASE = { + params: {}, + context: {} as AppLoadContext, + url: new URL(ROUTE_PATH, BASE_URL), + pattern: ROUTE_PATH, +} satisfies Omit + +afterEach(() => { + vi.unstubAllEnvs() +}) + +async function setupUser() { + return prisma.user.create({ + data: createUser(), + select: { email: true, username: true }, + }) +} + +function createForgotPasswordRequest( + usernameOrEmail: string, + headers: Record = {}, +) { + return new Request(new URL(ROUTE_PATH, BASE_URL), { + method: 'POST', + headers, + body: new URLSearchParams({ usernameOrEmail }), + }) +} + +async function getEmailedVerifyUrl(email: string) { + const sentEmail = await requireEmail(email) + const link = sentEmail.text.match( + /https?:\/\/[^\s\]]+\/verify\?[^\s\]]+/, + )?.[0] + invariant(link, 'verify link not found in email') + return new URL(link) +} + +test('emailed reset link ignores an untrusted X-Forwarded-Host', async () => { + vi.stubEnv('ALLOWED_HOSTS', 'www.epicstack.dev') + const user = await setupUser() + const response = await action({ + ...ACTION_ARGS_BASE, + request: createForgotPasswordRequest(user.username, { + host: 'www.epicstack.dev', + 'X-Forwarded-Host': 'attacker.example', + 'X-Forwarded-Proto': 'https', + }), + }) + invariant(response instanceof Response, 'response should be a Response') + expect(response.headers.get('location')).toMatch( + /^https:\/\/www\.epicstack\.dev\/verify\?/, + ) + const verifyUrl = await getEmailedVerifyUrl(user.email) + expect(verifyUrl.origin).toBe('https://www.epicstack.dev') + expect(verifyUrl.searchParams.get('code')).toBeTruthy() +}) + +test('emailed reset link falls back to the canonical host for an untrusted Host header', async () => { + vi.stubEnv('ALLOWED_HOSTS', 'www.epicstack.dev,epicstack.dev') + const user = await setupUser() + await action({ + ...ACTION_ARGS_BASE, + request: createForgotPasswordRequest(user.email, { + host: 'attacker.example', + 'X-Forwarded-Proto': 'https', + }), + }) + const verifyUrl = await getEmailedVerifyUrl(user.email) + expect(verifyUrl.origin).toBe('https://www.epicstack.dev') +}) + +test('emailed reset link uses the request host when it is allowed', async () => { + vi.stubEnv('ALLOWED_HOSTS', 'www.epicstack.dev,epicstack.dev') + const user = await setupUser() + await action({ + ...ACTION_ARGS_BASE, + request: createForgotPasswordRequest(user.username, { + host: 'EpicStack.dev', + 'X-Forwarded-Proto': 'https', + }), + }) + const verifyUrl = await getEmailedVerifyUrl(user.email) + expect(verifyUrl.origin).toBe('https://epicstack.dev') +}) + +test('the Fly.io app hostname is allowed without configuration', async () => { + vi.stubEnv('ALLOWED_HOSTS', '') + vi.stubEnv('FLY_APP_NAME', 'epic-stack-test') + const user = await setupUser() + await action({ + ...ACTION_ARGS_BASE, + request: createForgotPasswordRequest(user.username, { + host: 'epic-stack-test.fly.dev', + 'X-Forwarded-Proto': 'https', + }), + }) + const verifyUrl = await getEmailedVerifyUrl(user.email) + expect(verifyUrl.origin).toBe('https://epic-stack-test.fly.dev') +}) + +test('falls back to the Fly.io app hostname for an untrusted host', async () => { + vi.stubEnv('ALLOWED_HOSTS', '') + vi.stubEnv('FLY_APP_NAME', 'epic-stack-test') + const user = await setupUser() + await action({ + ...ACTION_ARGS_BASE, + request: createForgotPasswordRequest(user.username, { + host: 'epic-stack-test.fly.dev', + 'X-Forwarded-Host': 'attacker.example', + 'X-Forwarded-Proto': 'https', + }), + }) + const verifyUrl = await getEmailedVerifyUrl(user.email) + expect(verifyUrl.origin).toBe('https://epic-stack-test.fly.dev') +}) diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts index e762b1e60..81d01f973 100644 --- a/app/utils/env.server.ts +++ b/app/utils/env.server.ts @@ -19,6 +19,11 @@ const schema = z.object({ GITHUB_TOKEN: z.string().optional(), ALLOW_INDEXING: z.enum(['true', 'false']).optional(), + // Comma-separated hostnames the app is served from (like + // "example.com,www.example.com"). The first is the canonical host used for + // links in emails when a request's Host header isn't one of these. + // `${FLY_APP_NAME}.fly.dev` and localhost are always allowed. + ALLOWED_HOSTS: z.string().optional(), // Tigris Object Storage Configuration AWS_ACCESS_KEY_ID: z.string(), diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx index edcffae31..9e88b5f2c 100644 --- a/app/utils/misc.tsx +++ b/app/utils/misc.tsx @@ -61,12 +61,46 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +/** + * Hosts we trust to appear in the Host/X-Forwarded-Host headers. The first one + * is the canonical host used when the request's host is not trusted. + */ +function getAllowedHosts() { + const hosts = (process.env.ALLOWED_HOSTS ?? '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter(Boolean) + if (process.env.FLY_APP_NAME) { + hosts.push(`${process.env.FLY_APP_NAME}.fly.dev`.toLowerCase()) + } + return hosts +} + +function isLocalhost(host: string) { + const hostname = host.replace(/:\d+$/, '') + return ['localhost', '127.0.0.1', '[::1]'].includes(hostname) +} + +/** + * Returns the origin of the app (like https://example.com). Host headers are + * client-controlled, so the request's host is only used if it is in + * ALLOWED_HOSTS, is the Fly.io app hostname, or is localhost. Otherwise we fall + * back to the canonical host. This is important because we use this to + * generate links we email to users (like password reset links). + */ export function getDomainUrl(request: Request) { - const host = + const requestHost = ( request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') ?? new URL(request.url).host - const protocol = request.headers.get('X-Forwarded-Proto') ?? 'http' + ).toLowerCase() + const allowedHosts = getAllowedHosts() + const host = + allowedHosts.includes(requestHost) || isLocalhost(requestHost) + ? requestHost + : (allowedHosts[0] ?? `localhost:${process.env.PORT || 3000}`) + const protocol = + request.headers.get('X-Forwarded-Proto') === 'https' ? 'https' : 'http' return `${protocol}://${host}` } diff --git a/docs/decisions/048-allowed-hosts.md b/docs/decisions/048-allowed-hosts.md new file mode 100644 index 000000000..609363576 --- /dev/null +++ b/docs/decisions/048-allowed-hosts.md @@ -0,0 +1,42 @@ +# Allowed Hosts + +Date: 2026-10-07 + +Status: accepted + +## Context + +`getDomainUrl` builds the app's origin from the `X-Forwarded-Host` or `Host` +request headers. We use it to build the verification links we email to users +(signup, password reset, and email change), the passkey relying party ID and +origin, and the image optimizer's allowed origins. + +Both headers can be set by the client, and a proxy won't necessarily strip or +overwrite `X-Forwarded-Host`. That means anyone could trigger an email to a user +that contains a genuine verification code in a link pointing to a host they +control. + +We considered requiring a single canonical origin environment variable (like +`APP_URL`), but that would break every existing app that doesn't set it, and it +doesn't handle apps served from more than one hostname (like `example.com` and +`www.example.com`). + +## Decision + +`getDomainUrl` only uses the request's host if it's trusted. Trusted hosts are: + +- The comma-separated hostnames in a new optional `ALLOWED_HOSTS` environment + variable +- `${FLY_APP_NAME}.fly.dev` (`FLY_APP_NAME` is set automatically by Fly) +- `localhost`, `127.0.0.1`, and `[::1]` on any port + +If the request's host isn't trusted, we use the canonical host: the first entry +in `ALLOWED_HOSTS`, then `${FLY_APP_NAME}.fly.dev`, then `localhost:${PORT}`. + +## Consequences + +Apps deployed to Fly without a custom domain keep working with no configuration. +Apps on a custom domain need to set `ALLOWED_HOSTS` or their emailed links will +point to the `fly.dev` hostname and passkeys won't work on the custom domain. +Development over a LAN IP (rather than localhost) needs that IP added to +`ALLOWED_HOSTS` for emailed links to use it. diff --git a/docs/deployment.md b/docs/deployment.md index bc1737358..45e5d5092 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -75,6 +75,17 @@ Prior to your first deployment, you'll need to do a few things: fly secrets set ALLOW_INDEXING=false --app [YOUR_APP_NAME]-staging ``` +- If you serve your app from a custom domain, add an `ALLOWED_HOSTS` secret with + a comma-separated list of the hostnames your app is served from. The first one + is the canonical host. Links in emails (like password reset links) and + passkeys only use hostnames in this list, `[YOUR_APP_NAME].fly.dev`, or + `localhost` (learn more in [the security docs](./security.md#host-headers)): + + ```sh + fly secrets set ALLOWED_HOSTS=example.com,www.example.com --app [YOUR_APP_NAME] + fly secrets set ALLOWED_HOSTS=staging.example.com --app [YOUR_APP_NAME]-staging + ``` + 6. Create production database: Create a persistent volume for the sqlite database for both your staging and diff --git a/docs/security.md b/docs/security.md index 15a4759b6..f378812d9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -75,6 +75,26 @@ The Epic Stack has built-in support for honeypot fields. We use the [honeypot-related utilities](https://github.com/sergiodxa/remix-utils#form-honeypot) to do this. +## Host Headers + +The `Host` and `X-Forwarded-Host` request headers are controlled by the client, +so `getDomainUrl` in `app/utils/misc.tsx` only uses them if they match a trusted +hostname. Otherwise it falls back to the canonical host. This matters because +the result is used to build the links we email to users (like password reset +links), the passkey relying party, and the image optimizer's allowed origins. + +Trusted hostnames are: + +- The comma-separated hostnames in the `ALLOWED_HOSTS` environment variable (the + first one is the canonical host) +- `${FLY_APP_NAME}.fly.dev` (`FLY_APP_NAME` is set automatically by Fly) +- `localhost`, `127.0.0.1`, and `[::1]` (on any port) + +If you serve your app from a custom domain, set `ALLOWED_HOSTS` (see +[the deployment docs](./deployment.md)). If you don't, requests to your custom +domain will get links to `[YOUR_APP_NAME].fly.dev` in emails and passkeys won't +work on your custom domain. + ## Rate Limiting The Epic Stack uses a rate limiter to prevent abuse of the API. This is From e5b58a3283f6e33566199a5d4b1e2cad21cdd201 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:46 +0000 Subject: [PATCH 2/6] Apply the strict rate limit to /forgot-password Co-authored-by: Kent C. Dodds --- server/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/index.ts b/server/index.ts index 2a4d41963..3cbe675ce 100644 --- a/server/index.ts +++ b/server/index.ts @@ -127,6 +127,7 @@ app.use((req, res, next) => { '/verify', '/admin', '/onboarding', + '/forgot-password', '/reset-password', '/settings/profile', '/resources/login', From 88d7a7b44337f9af3639013be6ee96de4797d8c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:47 +0000 Subject: [PATCH 3/6] Require a verified primary email for GitHub auth Co-authored-by: Kent C. Dodds --- .../_auth/auth.$provider/callback.test.ts | 34 ++++++++++++++++++- app/utils/providers/github.server.ts | 6 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/routes/_auth/auth.$provider/callback.test.ts b/app/routes/_auth/auth.$provider/callback.test.ts index bdbe7c452..0bceec221 100644 --- a/app/routes/_auth/auth.$provider/callback.test.ts +++ b/app/routes/_auth/auth.$provider/callback.test.ts @@ -1,7 +1,7 @@ import { invariant } from '@epic-web/invariant' import { faker } from '@faker-js/faker' import { SetCookie } from '@mjackson/headers' -import { http } from 'msw' +import { HttpResponse, http } from 'msw' import { type AppLoadContext } from 'react-router' import { afterEach, expect, test } from 'vitest' import { twoFAVerificationType } from '#app/routes/settings/profile/two-factor/_layout.tsx' @@ -156,6 +156,38 @@ test('when a user exists with the same email, create connection and make session await expect(response).toHaveSessionForUser(userId) }) +test('does not sign in to an existing user when the GitHub primary email is unverified', async () => { + consoleError.mockImplementation(() => {}) + const githubUser = await insertGitHubUser() + const email = githubUser.primaryEmail.toLowerCase() + const { userId } = await setupUser({ ...createUser(), email }) + server.use( + http.get('https://api.github.com/user/emails', () => + HttpResponse.json( + githubUser.emails.map((e) => + e.primary ? { ...e, verified: false } : e, + ), + ), + ), + ) + const request = await setupRequest({ code: githubUser.code }) + const response = await loader({ + request, + ...LOADER_ARGS_BASE, + }).catch((e) => e) + + invariant(response instanceof Response, 'response should be a Response') + expect(response).toHaveRedirect('/login') + await expect(response).toSendToast( + expect.objectContaining({ title: 'Auth Failed', type: 'error' }), + ) + expect(response.headers.get('set-cookie')).not.toContain('en_session=') + const connection = await prisma.connection.findFirst({ + where: { userId, providerId: githubUser.profile.id.toString() }, + }) + expect(connection, 'a connection should not have been created').toBeNull() +}) + test('gives an error if the account is already connected to another user', async () => { const githubUser = await insertGitHubUser() await prisma.user.create({ diff --git a/app/utils/providers/github.server.ts b/app/utils/providers/github.server.ts index d2daeeb70..e540f2bbf 100644 --- a/app/utils/providers/github.server.ts +++ b/app/utils/providers/github.server.ts @@ -83,9 +83,11 @@ export class GitHubProvider implements AuthProvider { ) const rawEmails = await emailsResponse.json() const emails = GitHubEmailsResponseSchema.parse(rawEmails) - const email = emails.find((e) => e.primary)?.email + // The email is used to link to existing accounts, so it must be + // verified or anyone could claim an existing user's email address. + const email = emails.find((e) => e.primary && e.verified)?.email if (!email) { - throw new Error('Email not found') + throw new Error('Verified primary email not found') } return { From 36acd629f3ec0b86c6e3a49e482829606d114941 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:47 +0000 Subject: [PATCH 4/6] Sign out existing sessions when a password is reset Co-authored-by: Kent C. Dodds --- app/routes/_auth/reset-password.test.ts | 60 +++++++++++++++++++++++++ app/utils/auth.server.ts | 2 + 2 files changed, 62 insertions(+) create mode 100644 app/routes/_auth/reset-password.test.ts diff --git a/app/routes/_auth/reset-password.test.ts b/app/routes/_auth/reset-password.test.ts new file mode 100644 index 000000000..f9dabf402 --- /dev/null +++ b/app/routes/_auth/reset-password.test.ts @@ -0,0 +1,60 @@ +import { faker } from '@faker-js/faker' +import { type AppLoadContext } from 'react-router' +import { expect, test } from 'vitest' +import { + getSessionExpirationDate, + verifyUserPassword, +} from '#app/utils/auth.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { createPassword, createUser } from '#tests/db-utils.ts' +import { BASE_URL, convertSetCookieToCookie } from '#tests/utils.ts' +import { type Route } from './+types/reset-password.ts' +import { action, resetPasswordUsernameSessionKey } from './reset-password.tsx' + +const ROUTE_PATH = '/reset-password' + +test('resetting a password signs out all existing sessions', async () => { + const user = await prisma.user.create({ + data: { + ...createUser(), + password: { create: createPassword() }, + sessions: { + create: [ + { expirationDate: getSessionExpirationDate() }, + { expirationDate: getSessionExpirationDate() }, + ], + }, + }, + select: { id: true, username: true }, + }) + + const verifySession = await verifySessionStorage.getSession() + verifySession.set(resetPasswordUsernameSessionKey, user.username) + const cookie = convertSetCookieToCookie( + await verifySessionStorage.commitSession(verifySession), + ) + const newPassword = faker.internet.password() + const request = new Request(new URL(ROUTE_PATH, BASE_URL), { + method: 'POST', + headers: { cookie }, + body: new URLSearchParams({ + password: newPassword, + confirmPassword: newPassword, + }), + }) + + const response = await action({ + request, + params: {}, + context: {} as AppLoadContext, + url: new URL(ROUTE_PATH, BASE_URL), + pattern: ROUTE_PATH, + } satisfies Route.ActionArgs) + + expect(response).toHaveRedirect('/login') + expect(await verifyUserPassword({ id: user.id }, newPassword)).toEqual({ + id: user.id, + }) + expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0) +}) diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index f785ea88d..2ea1f47b3 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -108,6 +108,8 @@ export async function resetUserPassword({ hash: hashedPassword, }, }, + // a password reset should sign out anyone using the old password + sessions: { deleteMany: {} }, }, }) } From a68f11e892bb369b275bdf5dcdf0ee4e1f1d5325 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:47 +0000 Subject: [PATCH 5/6] Use safeRedirect in the theme switch action Co-authored-by: Kent C. Dodds --- app/routes/resources/theme-switch.test.ts | 31 +++++++++++++++++++++++ app/routes/resources/theme-switch.tsx | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 app/routes/resources/theme-switch.test.ts diff --git a/app/routes/resources/theme-switch.test.ts b/app/routes/resources/theme-switch.test.ts new file mode 100644 index 000000000..fc4202230 --- /dev/null +++ b/app/routes/resources/theme-switch.test.ts @@ -0,0 +1,31 @@ +import { type AppLoadContext } from 'react-router' +import { expect, test } from 'vitest' +import { BASE_URL } from '#tests/utils.ts' +import { type Route } from './+types/theme-switch.ts' +import { action } from './theme-switch.tsx' + +const ROUTE_PATH = '/resources/theme-switch' + +function submitTheme(redirectTo: string) { + const request = new Request(new URL(ROUTE_PATH, BASE_URL), { + method: 'POST', + body: new URLSearchParams({ theme: 'dark', redirectTo }), + }) + return action({ + request, + params: {}, + context: {} as AppLoadContext, + url: new URL(ROUTE_PATH, BASE_URL), + pattern: ROUTE_PATH, + } satisfies Route.ActionArgs) +} + +test('redirects back to the given path', async () => { + const response = await submitTheme('/users/kody?tab=notes') + expect(response).toHaveRedirect('/users/kody?tab=notes') +}) + +test('does not redirect to other origins', async () => { + expect(await submitTheme('https://attacker.example')).toHaveRedirect('/') + expect(await submitTheme('//attacker.example')).toHaveRedirect('/') +}) diff --git a/app/routes/resources/theme-switch.tsx b/app/routes/resources/theme-switch.tsx index e466824b2..e336254b4 100644 --- a/app/routes/resources/theme-switch.tsx +++ b/app/routes/resources/theme-switch.tsx @@ -2,6 +2,7 @@ import { useForm, getFormProps } from '@conform-to/react' import { parseWithZod } from '@conform-to/zod' import { invariantResponse } from '@epic-web/invariant' import { data, redirect, useFetcher, useFetchers } from 'react-router' +import { safeRedirect } from 'remix-utils/safe-redirect' import { ServerOnly } from 'remix-utils/server-only' import { z } from 'zod' import { Icon } from '#app/components/ui/icon.tsx' @@ -32,7 +33,7 @@ export async function action({ request }: Route.ActionArgs) { headers: { 'set-cookie': setTheme(theme) }, } if (redirectTo) { - return redirect(redirectTo, responseInit) + return redirect(safeRedirect(redirectTo), responseInit) } else { return data({ result: submission.reply() }, responseInit) } From 624ed741fb8defb485d55e9e7db3a382790168d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 13:33:47 +0000 Subject: [PATCH 6/6] Add SECURITY.md Co-authored-by: Kent C. Dodds --- SECURITY.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..f43be33eb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not report security vulnerabilities in public issues, discussions, or +pull requests. + +Instead, report them privately using +[GitHub private vulnerability reporting](https://github.com/epicweb-dev/epic-stack/security/advisories/new). +Please include a description of the issue, the steps to reproduce it, and the +affected files or versions if you know them. + +We'll respond as soon as we can and keep you updated while we work on a fix. + +## Supported Versions + +The Epic Stack is a template, so fixes are made on the `main` branch. Apps +generated from the Epic Stack don't receive updates automatically. See +[managing updates](./docs/managing-updates.md) for how to bring fixes into your +app.