diff --git a/AGENTS.md b/AGENTS.md index 71038bd6..673ead82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Personal Agent Proxy holds the integrations and personal information an AI agent - **Model**: an integration from the catalog (`@proxy/integrations`: Email over IMAP and SMTP, Granola's meeting notes, Notion's pages and Linear's issues over their MCP servers, PostgreSQL and MySQL over their own protocols) has the actions an agent can be allowed with a connection of it (Read, Archive, Write drafts, Send) and collections, each a list of fields with the actions reading and writing it need, and the commands it offers beyond editing (Mark as read, Archive, Send), each needing one action. A command runs on a `record`, where its `where` picks out which (Archive only in the inbox, Send only on a draft), or on values typed in (`new`: sending a new email). A mailbox is one collection, its folder a field the agent side filters by. A `nested` collection is a tree, as Notion's pages are: a record can hold others of the collection (`hasChildren`), listed inside it with the list tool's `parent` and made inside it with the create tool's, and access is the same anywhere in it. A collection without a `read` action is never listed or opened, only run, as a database's queries are, and a `new` command marked `readOnly` counts as a read for MCP clients. Integrations join the catalog once they work end to end; Google Workspace comes later. Any other service with an MCP server is connected as a custom MCP server: the server's tools are the actions, each allowed on its own, and there are no collections; an agent runs a tool with the params the server describes and gets back what it answered. A connection is a connected integration and its account, owned by the organization; an integration can be connected more than once, such as two mailboxes. Information is the built-in `info` connection (addresses, payment cards, notes), made with the organization, so access and the agent side treat it like any other. Nothing is copied from a provider: every request goes to it live. Fields marked `system` are set by the provider, never typed in. - **Access** is one list of actions per connection, and every action the catalog lists can be set: what Personal Agent Proxy can do with the provider is the limit. Two layers: the connection's default (a `ConnectionDefault` row per action that is on) and an agent's own setting, on or off, where it differs (`AgentGrant`). An agent gets its own setting, else the default as it is now; an action that `requires` another counts only with it (Archive needs Read; Send needs nothing, so an agent can send without reading the mailbox). A collection's `writes` name the action creating, editing and deleting need, and `editable` which records they apply to (drafts). An agent login is for one agent from `AGENT_PROVIDERS`; each organization can have one login per company, named by it, and any number of custom MCP agents (`custom`, for any MCP client not in the list), each named by the person and told apart by their names (`Agent` is unique by organization, provider and name). It has a generated username and a password that is shown once. Every request on the agent side is an audit entry, allowed or denied, with what a list searched for. -- **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api); the sign-in page says that continuing accepts the terms and the privacy policy, on the website, and a sign-up records it (`User.termsAcceptedAt`); accounts from before the notice have none until they are asked. A session is a `Session` row and an httpOnly cookie; the web app's `/api/me` loader on the human side's route redirects to `/login?callbackUrl=…` without one. Every user gets one `Organization` at sign-up, created with the user. `ALLOWED_SIGNUP_EMAILS` limits who can sign up; the login page asks `/auth/methods` whether to offer Google. In development the login page has a Create dev user button; in development, or without `RESEND_KEY`, the api prints magic links instead of emailing them. Cookies are secure when `APP_URL` is HTTPS. A new organization first goes through the welcome flow (`/welcome`, outside the human side's layout; the human side redirects there until `Organization.onboardedAt` is set, which `POST /api/me/onboarded` does when the flow is finished or skipped): step one is `AgentSetup` (`src/components/agent-setup.tsx`, which New agent and setting an agent up again use too): it picks the agent from `AGENT_PROVIDERS`, the choice in the address (`?agent=claude`) so the browser's back button returns to it, and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code and Hermes Agent use terminal commands) while the page polls for the login its sign-in makes, and over http, as in development, only a `local` agent (Claude Code, Claude Desktop, Hermes Agent) can be picked, the rest greyed out with a pointer to self-hosting on a server with a domain, a `login` agent given a login at once, as a message to paste to it with the sign-in page, username and password, or, for a `vault` agent (Instinct), which won't take a password in chat, as three steps: a message asking it for a link to its vault, the login to fill in on the page it sends, and a message telling it to sign in; step two (`/welcome/connections`) is the catalog, a plus per integration with how many accounts are connected: a mailbox goes to `/welcome/connections/email`, the form on a page with Continue once signed in, and a sign-in started with `?returnTo=welcome` comes back to `/welcome/connections/:id`, the new connection's permissions (its defaults, saved as they are ticked) with Confirm back to the catalog; a database goes to `/welcome/connections/postgres` or `/welcome/connections/mysql` the same way as a mailbox. Organizations from before the flow were marked done by its migration. +- **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api); a Google sign-in keeps the Google account's id (an `Account` row) and none of Google's tokens; the sign-in page says that continuing accepts the terms and the privacy policy, on the website, and a sign-up records it (`User.termsAcceptedAt`); accounts from before the notice have none until they are asked. A session is a `Session` row and an httpOnly cookie; the web app's `/api/me` loader on the human side's route redirects to `/login?callbackUrl=…` without one. Every user gets one `Organization` at sign-up, created with the user. `ALLOWED_SIGNUP_EMAILS` limits who can sign up; the login page asks `/auth/methods` whether to offer Google. In development the login page has a Create dev user button; in development, or without `RESEND_KEY`, the api prints magic links instead of emailing them. Cookies are secure when `APP_URL` is HTTPS. A new organization first goes through the welcome flow (`/welcome`, outside the human side's layout; the human side redirects there until `Organization.onboardedAt` is set, which `POST /api/me/onboarded` does when the flow is finished or skipped): step one is `AgentSetup` (`src/components/agent-setup.tsx`, which New agent and setting an agent up again use too): it picks the agent from `AGENT_PROVIDERS`, the choice in the address (`?agent=claude`) so the browser's back button returns to it, and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code and Hermes Agent use terminal commands) while the page polls for the login its sign-in makes, and over http, as in development, only a `local` agent (Claude Code, Claude Desktop, Hermes Agent) can be picked, the rest greyed out with a pointer to self-hosting on a server with a domain, a `login` agent given a login at once, as a message to paste to it with the sign-in page, username and password, or, for a `vault` agent (Instinct), which won't take a password in chat, as three steps: a message asking it for a link to its vault, the login to fill in on the page it sends, and a message telling it to sign in; step two (`/welcome/connections`) is the catalog, a plus per integration with how many accounts are connected: a mailbox goes to `/welcome/connections/email`, the form on a page with Continue once signed in, and a sign-in started with `?returnTo=welcome` comes back to `/welcome/connections/:id`, the new connection's permissions (its defaults, saved as they are ticked) with Confirm back to the catalog; a database goes to `/welcome/connections/postgres` or `/welcome/connections/mysql` the same way as a mailbox. Organizations from before the flow were marked done by its migration. - **Human side** behind `/login`: `/connections` (a right click on a row renames the connection, `PUT /api/connections/:id/name`, an empty name being none of its own, which a custom MCP server can't have, or deletes it, each behind a dialog), `/connections/new` (the catalog, a plus per integration with how many accounts are connected: `/connections/new/email` connects a mailbox with an app password, with a Test connection that signs in and saves nothing, `/connections/new/postgres` and `/connections/new/mysql` a database the same way, `/connections/new/mcp` a custom MCP server under a name of the person's, by its address, with an access token where the server takes one instead of a sign-in (`ConnectMcpForm`, which sends the browser to the server's sign-in when the api says it asks for one), and Granola, Notion and Linear are connected by signing in to them; `ConnectEmailForm`, `ConnectDatabaseForm`, `ConnectMcpForm` and `IntegrationCatalog` serve the welcome flow too, at `/welcome/connections/`), `/connections/:id` (titled by the name the person gave it where they did, as the list and the agent side are, the defaults under Permissions, with how many agents differ per action, a custom MCP server's tools being its actions, with a button that asks the server for them again, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents`, `/agents/new` (the welcome flow's agent step, among the companies without a login yet and Custom MCP agent, on to the agent's page), `/agents/:id/setup` (the same for one agent, to hand it a new password or add the connector again; the only place a password shows, once), `/agents/:id` (what the agent can reach and did, the MCP clients working as it when there are any; access laid out like an editor's settings: one checkbox per action of each connection, the only control for it, each connection folded by default to a row saying Default or Changed; a setting of the agent's own is marked with a bar, a button that makes it the connection's default for every agent, and one that resets it, and a filter and Changed only narrow the list; `ConnectionAccess` serves both pages), `/activity` (filters in the URL, so the agent and connection pages link to their part of the log; a custom server's tool reads "Ran Search docs with {…}"). Each page has a loader in `src/loaders.ts` and revalidates after a change. Layout follows the spanish repo: the shadcn sidebar as a rail, an `h-12` header, one `max-w-3xl` column, one-line `RowList` rows. Type has one size, `text-sm`, in rows, summaries and hints alike, except column headers (`RowHeader`), which are `text-xs`: the thing itself in the normal colour, what is said about it (column headers, times, summaries, descriptions) in `text-muted-foreground`, and `font-medium` only for page and section titles. - **MCP**: an agent that speaks MCP (Poke, Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, or any other as a Custom MCP agent) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`, or makes one there: a client whose name points at no company's agent is offered a new custom agent named after it, which the person can rename. Setting up a custom agent waits for one that wasn't there before to sign in, since there can be several. The agent page shows the server's address and the MCP clients working as the login, each with a Disconnect; the activity log marks requests that came in over MCP (`AuditEntry.via`). - **Agent side** behind `/agent/login`, with its own cookie: `/agent` lists what the login can do with each connection and the lists it can open, `/agent/:connection/tool/:tool` one of a custom MCP server's tools, a form of its params built from the tool's schema and what it answered below (the home page lists the tools the login may run), `/agent/:connection/:collection` a collection's records (newest first, 50 to a page with an Older link, narrowed by its `filterField`, such as the folder, in the address as the list tool takes it, `?folder=Draft`, and searched where the collection has a `searchHint`, which says what the search takes; in a nested collection `?parent=` opens it inside a record, with the records above it as breadcrumbs, a record holding others opening into them, and New making one there), `…/new` and `…/:record` the generic `RecordForm` and `RecordFields`, with a button per command the agent may run on the record, Send beside Save as draft on a new email, and a command that answers something, as running a query does, shown under the form (loaders in `src/agent-loaders.ts`). Every page runs a tool (`toolName` in `@proxy/integrations`) through the api. It is built for a model driving a browser: no sidebar, no icon-only buttons, nothing that only shows on hover. Lists it can neither read nor add to are left out, and one it can only add to (sending without Read) opens on its form; asking for one anyway shows the api's refusal, which it logs as denied. diff --git a/packages/api/src/agent-side/tool-params.ts b/packages/api/src/agent-side/tool-params.ts index 36f47e32..9a5699df 100644 --- a/packages/api/src/agent-side/tool-params.ts +++ b/packages/api/src/agent-side/tool-params.ts @@ -24,7 +24,8 @@ export function parseToolParams(tool: CollectionTool, params: unknown): Result = {}; for (const [key, value] of Object.entries(given)) { - const property = properties[key]; + // Its own properties alone: `constructor` or `toString` is no param, though every object has one. + const property = Object.hasOwn(properties, key) ? properties[key] : undefined; if (!property) { const takes = known.length > 0 ? `; it takes ${known.join(', ')}` : '; it takes none'; return Err(ApiErr.validationError(`${tool.name} takes no ${key}${takes}`)); diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index 387ba07c..be0fd080 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -162,8 +162,9 @@ describe('runAgentTool', () => { const outsideEnum = await runAgentTool(caller, 'mail-1', 'emails_list', {folder: 'Spam'}); const missingId = await runAgentTool(caller, 'mail-1', 'emails_get', {}); const notText = await runAgentTool(caller, 'mail-1', 'emails_get', {id: 7}); + const inherited = await runAgentTool(caller, 'mail-1', 'emails_get', {id: 'inbox-1', constructor: 'x'}); - for (const result of [unknown, outsideEnum, missingId, notText]) { + for (const result of [unknown, outsideEnum, missingId, notText, inherited]) { expect(result.unwrapErr().kind).toBe('validation_error'); } expect(connector.list).not.toHaveBeenCalled(); diff --git a/packages/api/src/auth/google.test.ts b/packages/api/src/auth/google.test.ts index 8fb66448..cd1baa21 100644 --- a/packages/api/src/auth/google.test.ts +++ b/packages/api/src/auth/google.test.ts @@ -264,12 +264,6 @@ describe('handleGoogleCallbackRoute', () => { type: 'oauth', provider: 'google', providerAccountId: googleProfile.sub, - access_token: 'access-token-1', - refresh_token: 'refresh-token-1', - expires_at: expect.any(Number), - token_type: 'Bearer', - scope: 'openid email profile', - id_token: expect.stringContaining('.'), }); expect(createSession).toHaveBeenCalledTimes(1); expect((createSession.mock.calls[0]?.[0] as {userId: string}).userId).toBe('user-new'); diff --git a/packages/api/src/auth/google.ts b/packages/api/src/auth/google.ts index 8e0959fe..2b27ce08 100644 --- a/packages/api/src/auth/google.ts +++ b/packages/api/src/auth/google.ts @@ -16,7 +16,8 @@ import {canSignUp, establishSessionAndRedirect, queryParam, redirectToLoginError * Google sign-in, an OAuth authorization-code flow with PKCE. A Google identity is an Account * row (provider 'google', providerAccountId = the profile's `sub`); an email that already * belongs to a user who signed up another way gets `OAuthAccountNotLinked` rather than being - * attached silently. + * attached silently. Google's tokens are only read for the profile in the id_token and never + * stored: signing in needs nothing from Google afterwards. * * Both endpoints are top-level browser navigations (302 redirects), not fetch calls. */ @@ -163,13 +164,9 @@ export async function handleGoogleStartRoute(req: Request, res: Response): Promi res.redirect(url.toString()); } +// Only the id_token: the rest of Google's answer, its access token among it, is dropped as it is read. const googleTokensSchema = z.object({ - access_token: z.string().min(1), id_token: z.string().min(1), - expires_in: z.number().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), - token_type: z.string().optional(), }); /** @@ -272,8 +269,7 @@ export async function handleGoogleCallbackRoute(req: Request, res: Response): Pr return; } - const tokens = tokensResult.value; - const profile = decodeIdTokenProfile(tokens.id_token); + const profile = decodeIdTokenProfile(tokensResult.value.id_token); if (!profile) { log.error('Google OAuth id_token missing sub or email'); redirectToLoginError(res, config.appUrl, 'OAuthCallback'); @@ -288,7 +284,6 @@ export async function handleGoogleCallbackRoute(req: Request, res: Response): Pr } if (accountResult.value) { - // The stored Account tokens are not refreshed on repeat sign-ins. const userResult = await getUserFromId(accountResult.value.userId); if (userResult.isErr() || !userResult.value) { log.error('Google OAuth account points at a missing user', {userId: accountResult.value.userId}); @@ -341,12 +336,6 @@ export async function handleGoogleCallbackRoute(req: Request, res: Response): Pr type: 'oauth', provider: 'google', providerAccountId: profile.sub, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token ?? null, - expires_at: tokens.expires_in === undefined ? null : Math.floor(Date.now() / 1000) + tokens.expires_in, - token_type: tokens.token_type ?? null, - scope: tokens.scope ?? null, - id_token: tokens.id_token, }); if (accountCreateResult.isErr()) { log.error('Google OAuth account creation failed', serializeError(accountCreateResult.error)); diff --git a/packages/api/src/oauth/oauth.test.ts b/packages/api/src/oauth/oauth.test.ts index 40911bca..ce9da35e 100644 --- a/packages/api/src/oauth/oauth.test.ts +++ b/packages/api/src/oauth/oauth.test.ts @@ -337,6 +337,13 @@ describe('handleTokenRoute', () => { expect(missing.body).toMatchObject({error: 'invalid_client'}); }); + test('a Basic header that does not decode is an unknown client, not a failure', async () => { + const res = await token({grant_type: 'refresh_token', refresh_token: 'refresh-1'}, {authorization: `Basic ${Buffer.from('client-1:%E0%A4%A').toString('base64')}`}); + + expect(res.statusCode).toBe(401); + expect(res.body).toMatchObject({error: 'invalid_client'}); + }); + test('any other grant type is unsupported', async () => { const res = await token({grant_type: 'password', client_id: 'client-1'}); diff --git a/packages/api/src/oauth/token.ts b/packages/api/src/oauth/token.ts index cb84bf80..5426e105 100644 --- a/packages/api/src/oauth/token.ts +++ b/packages/api/src/oauth/token.ts @@ -1,5 +1,5 @@ import type {Request, Response} from 'express'; -import {Err, type Result} from 'ts-results-es'; +import {Err, Result} from 'ts-results-es'; import {type ApiError, Do} from '@proxy/utils'; import {createOAuthGrant, getOAuthClient, getOAuthGrantByRefreshToken, rotateOAuthGrant, takeOAuthCode, type OAuthClientRow, type OAuthTokens} from '@proxy/db/oauth'; @@ -31,7 +31,8 @@ function field(body: Record, name: string): string | null { return typeof value === 'string' && value !== '' ? value : null; } -// HTTP Basic, as `client_secret_basic` sends the client's id and secret, each form-encoded. +// HTTP Basic, as `client_secret_basic` sends the client's id and secret, each form-encoded. One +// that doesn't decode is no credentials, so the client is refused rather than the request failing. function basicCredentials(header: string | undefined): {id: string; secret: string} | null { if (!header?.startsWith('Basic ')) { return null; @@ -41,7 +42,7 @@ function basicCredentials(header: string | undefined): {id: string; secret: stri if (colon < 0) { return null; } - return {id: decodeURIComponent(decoded.slice(0, colon)), secret: decodeURIComponent(decoded.slice(colon + 1))}; + return Result.wrap(() => ({id: decodeURIComponent(decoded.slice(0, colon)), secret: decodeURIComponent(decoded.slice(colon + 1))})).unwrapOr(null); } function authenticateClient(req: Request, body: Record): Promise> { diff --git a/packages/api/src/server/create-http-app.ts b/packages/api/src/server/create-http-app.ts index 3a101ac2..aecc5cc1 100644 --- a/packages/api/src/server/create-http-app.ts +++ b/packages/api/src/server/create-http-app.ts @@ -7,6 +7,7 @@ import express from 'express'; import {log} from '../observability/log'; import {env} from '../utils/env'; import {createApiRouter} from './create-api-router'; +import {setSecurityHeaders} from './security-headers'; import {serveWeb} from './serve-web'; // The web app's build sits at a fixed place beside this package, packages/web/dist, in the repo @@ -20,6 +21,8 @@ export function createHttpApp({webDist = defaultWebDist}: {webDist?: string} = { // Behind a TLS-terminating proxy, trust X-Forwarded-* so `req.secure` and `req.hostname` // describe the original request. app.set('trust proxy', true); + app.disable('x-powered-by'); + app.use(setSecurityHeaders); app.get('/health', (_req, res) => { res.json({ok: true}); diff --git a/packages/api/src/server/security-headers.test.ts b/packages/api/src/server/security-headers.test.ts new file mode 100644 index 00000000..dfbb3497 --- /dev/null +++ b/packages/api/src/server/security-headers.test.ts @@ -0,0 +1,50 @@ +import {afterAll, beforeEach, describe, expect, mock, test} from 'bun:test'; +import type {Server} from 'node:http'; +import type {AddressInfo} from 'node:net'; + +const mockEnv: {NODE_ENV: string; APP_URL: string} = {NODE_ENV: 'test', APP_URL: 'https://app.example.com'}; + +mock.module('../utils/env', () => ({env: mockEnv})); + +const servers: Server[] = []; + +// The whole app without a web app build, as the api alone is served in development. +async function start(): Promise { + const {createHttpApp} = await import('./create-http-app'); + const server = createHttpApp({webDist: '/nonexistent'}).listen(0); + servers.push(server); + return `http://localhost:${(server.address() as AddressInfo).port}`; +} + +beforeEach(() => { + mockEnv.APP_URL = 'https://app.example.com'; +}); + +afterAll(() => { + for (const server of servers) { + server.close(); + } +}); + +describe('security headers', () => { + test("on the api's answers, its errors and its 404s, without saying it runs Express", async () => { + const base = await start(); + for (const path of ['/health', '/auth/methods', '/api/me', '/nope']) { + const res = await fetch(`${base}${path}`); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin'); + expect(res.headers.get('x-frame-options')).toBe('DENY'); + expect(res.headers.get('content-security-policy')).toBe("frame-ancestors 'none'"); + expect(res.headers.get('strict-transport-security')).toBe('max-age=31536000'); + expect(res.headers.get('x-powered-by')).toBeNull(); + } + }); + + test('no HSTS where Personal Agent Proxy is served over plain HTTP', async () => { + mockEnv.APP_URL = 'http://localhost'; + const base = await start(); + const res = await fetch(`${base}/health`); + expect(res.headers.get('strict-transport-security')).toBeNull(); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + }); +}); diff --git a/packages/api/src/server/security-headers.ts b/packages/api/src/server/security-headers.ts new file mode 100644 index 00000000..65c065d1 --- /dev/null +++ b/packages/api/src/server/security-headers.ts @@ -0,0 +1,23 @@ +import type {NextFunction, Request, Response} from 'express'; + +import {useSecureCookies} from '../auth/session'; + +// A year, the least browsers' preload lists take, and what scanners expect. +const hstsMaxAgeSeconds = 365 * 24 * 60 * 60; + +/** + * On every response, the api's and the web app's alike: no guessing a file's type from its + * contents, no full URLs sent to other sites, and no framing by any site, the web app's own pages + * included. Served over HTTPS, browsers are also told to use nothing else from then on; whatever + * terminates TLS in front, Render or Caddy, adds no such header itself. + */ +export function setSecurityHeaders(_req: Request, res: Response, next: NextFunction): void { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); + if (useSecureCookies()) { + res.setHeader('Strict-Transport-Security', `max-age=${hstsMaxAgeSeconds}`); + } + next(); +} diff --git a/packages/api/src/server/serve-web.test.ts b/packages/api/src/server/serve-web.test.ts index d42ee420..8027983b 100644 --- a/packages/api/src/server/serve-web.test.ts +++ b/packages/api/src/server/serve-web.test.ts @@ -43,7 +43,7 @@ describe('the web app on the api origin', () => { expect(res.headers.get('cache-control')).toBe('no-cache'); expect(res.headers.get('x-content-type-options')).toBe('nosniff'); expect(res.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin'); - expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN'); + expect(res.headers.get('x-frame-options')).toBe('DENY'); expect(await res.text()).toContain('Personal Agent Proxy'); } }); @@ -54,7 +54,7 @@ describe('the web app on the api origin', () => { const asset = await fetch(`${base}/assets/index-abc.js`); expect(asset.status).toBe(200); expect(asset.headers.get('cache-control')).toContain('immutable'); - expect(asset.headers.get('x-frame-options')).toBe('SAMEORIGIN'); + expect(asset.headers.get('x-frame-options')).toBe('DENY'); }); test('a missing asset is a 404, not the page', async () => { diff --git a/packages/api/src/server/serve-web.ts b/packages/api/src/server/serve-web.ts index d96981ac..3858ef51 100644 --- a/packages/api/src/server/serve-web.ts +++ b/packages/api/src/server/serve-web.ts @@ -10,14 +10,6 @@ function isApiPath(path: string): boolean { return apiPaths.some((apiPath) => path === apiPath || path.startsWith(`${apiPath}/`)); } -// On everything the web app is served with: no guessing a file's type from its contents, no full -// URLs sent to other sites, and no framing by them. -function setSecurityHeaders(res: express.Response): void { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - res.setHeader('X-Frame-Options', 'SAMEORIGIN'); -} - /** * Serves the web app's build from `dir`, so one server is the whole of Personal Agent Proxy on one origin: the * files as they are, and `index.html` for every other path. @@ -28,8 +20,8 @@ export function serveWeb(dir: string): express.Router { // Vite names assets by their content, so a name never changes what it serves. A missing one is // a 404, not the app's page. - router.use('/assets', express.static(join(root, 'assets'), {immutable: true, maxAge: '1y', fallthrough: false, setHeaders: setSecurityHeaders})); - router.use(express.static(root, {index: false, setHeaders: setSecurityHeaders})); + router.use('/assets', express.static(join(root, 'assets'), {immutable: true, maxAge: '1y', fallthrough: false})); + router.use(express.static(root, {index: false})); router.get('*', (req, res, next) => { if (isApiPath(req.path)) { @@ -37,7 +29,6 @@ export function serveWeb(dir: string): express.Router { return; } - setSecurityHeaders(res); // Always revalidated, so a new release reaches the browser on its next load. res.setHeader('Cache-Control', 'no-cache'); // Without a callback, Express passes a failure to the error handler itself, leaving out diff --git a/packages/api/src/utils/secret-crypto.test.ts b/packages/api/src/utils/secret-crypto.test.ts index 3fdbaf8c..d9465e1c 100644 --- a/packages/api/src/utils/secret-crypto.test.ts +++ b/packages/api/src/utils/secret-crypto.test.ts @@ -42,6 +42,16 @@ describe('secret crypto', () => { expect(decryptSecret(forged).unwrapErr().kind).toBe('internal_error'); }); + test('fails with a shortened tag, though it is the start of the right one', async () => { + const {encryptSecret, decryptSecret} = await import('./secret-crypto'); + const [version, iv, tag, ciphertext] = encryptSecret('secret').unwrap().split('.'); + const shortTag = Buffer.from(tag ?? '', 'base64url') + .subarray(0, 4) + .toString('base64url'); + + expect(decryptSecret([version, iv, shortTag, ciphertext].join('.')).unwrapErr().kind).toBe('internal_error'); + }); + test('fails without a key', async () => { mockEnv.ENCRYPTION_KEY = undefined; const {encryptSecret} = await import('./secret-crypto'); diff --git a/packages/api/src/utils/secret-crypto.ts b/packages/api/src/utils/secret-crypto.ts index 7861ec67..ebc8a3c5 100644 --- a/packages/api/src/utils/secret-crypto.ts +++ b/packages/api/src/utils/secret-crypto.ts @@ -9,6 +9,9 @@ import {env} from './env'; // a new key or cipher later without touching what is already stored. const VERSION = 'v1'; const ALGORITHM = 'aes-256-gcm'; +// The whole tag, always: told nothing, decryption would take a shortened one, which is far easier +// to forge. +const AUTH_TAG_LENGTH = 16; function encryptionKey(): Result { if (!env.ENCRYPTION_KEY) { @@ -20,7 +23,7 @@ function encryptionKey(): Result { export function encryptSecret(plaintext: string): Result { return encryptionKey().map((key) => { const iv = randomBytes(12); - const cipher = createCipheriv(ALGORITHM, key, iv); + const cipher = createCipheriv(ALGORITHM, key, iv, {authTagLength: AUTH_TAG_LENGTH}); const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const parts = [iv, cipher.getAuthTag(), ciphertext].map((part) => part.toString('base64url')); return [VERSION, ...parts].join('.'); @@ -36,7 +39,7 @@ export function decryptSecret(stored: string): Result { return encryptionKey().andThen((key) => Result.wrap(() => { - const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'base64url')); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'base64url'), {authTagLength: AUTH_TAG_LENGTH}); decipher.setAuthTag(Buffer.from(tag, 'base64url')); return Buffer.concat([decipher.update(Buffer.from(ciphertext, 'base64url')), decipher.final()]).toString('utf8'); }).mapErr((cause) => ApiErr.internalError(cause)), diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d874cb1d..577cdc40 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -39,7 +39,8 @@ model User { orgMembers OrgMember[] } -// A sign-in provider identity linked to a user, such as their Google account. +// A sign-in provider identity linked to a user, such as their Google account. The token columns +// are no longer written: sign-in needs nothing from the provider afterwards. model Account { id String @id @default(cuid()) userId String diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 541262e0..601b9a4a 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -127,18 +127,12 @@ export async function useVerificationToken(args: { }); } -/** The token columns are snake_case because they mirror the OAuth token response. */ +/** The identity alone: the provider's tokens are never stored, so the token columns stay empty. */ export async function createOAuthAccount(data: { userId: string; type: string; provider: string; providerAccountId: string; - access_token: string | null; - refresh_token: string | null; - expires_at: number | null; - token_type: string | null; - scope: string | null; - id_token: string | null; }): Promise> { return (await wrapDb(() => db.account.create({data}))).map(() => undefined); } diff --git a/render.yaml b/render.yaml index 41b74943..171b2f88 100644 --- a/render.yaml +++ b/render.yaml @@ -89,6 +89,22 @@ services: - path: /_next/static/* name: Cache-Control value: public, max-age=31536000, immutable + # The headers the app sends itself (packages/api/src/server/security-headers.ts). + - path: /* + name: X-Content-Type-Options + value: nosniff + - path: /* + name: Referrer-Policy + value: strict-origin-when-cross-origin + - path: /* + name: X-Frame-Options + value: DENY + - path: /* + name: Content-Security-Policy + value: frame-ancestors 'none' + - path: /* + name: Strict-Transport-Security + value: max-age=31536000 envVars: # The version the repo is developed and the image built with. - key: BUN_VERSION @@ -113,6 +129,21 @@ services: - path: /_next/static/* name: Cache-Control value: public, max-age=31536000, immutable + - path: /* + name: X-Content-Type-Options + value: nosniff + - path: /* + name: Referrer-Policy + value: strict-origin-when-cross-origin + - path: /* + name: X-Frame-Options + value: DENY + - path: /* + name: Content-Security-Policy + value: frame-ancestors 'none' + - path: /* + name: Strict-Transport-Security + value: max-age=31536000 envVars: - key: BUN_VERSION value: 1.4.2