diff --git a/docs/channels.md b/docs/channels.md index 28ab34807..f1fd2217d 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -46,21 +46,32 @@ import * as http from 'node:http'; import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent'; const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() }); -const channels = mountChannels(agent, [httpChannel()]); +const channels = mountChannels(agent, [ + httpChannel({ verify: async (req) => req.headers.authorization === `Bearer ${process.env.API_TOKEN}` }), +]); const server = http.createServer((req, res) => { void channels(req, res).then((handled) => { if (!handled) res.writeHead(404).end(); }); }); -server.listen(3000); +server.listen(3000, '127.0.0.1'); ``` ```bash -curl -s localhost:3000/channels/http -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}' +curl -s localhost:3000/channels/http -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}' # {"sessionId":"http_user-42-...","text":"...","finishReason":"stop"} ``` +### Securing the http channel + +Sessions are keyed by the `sessionKey` the caller sends, so an `httpChannel()` +without `verify` lets anyone who can reach the port read or continue another +user's conversation by guessing a key. It logs a warning once when created. +Always pass `verify` (a bearer-token check, as above, or your own session +check), bind to `127.0.0.1` unless a proxy or firewall fronts it, and derive +`sessionKey` from the authenticated user rather than trusting the client. + The handler resolves `true` when it served the request and `false` (nothing written) for any other route, like the `/chat` routes of `lousho dev`, so you can mount it next to your own routes. diff --git a/docs/deployment.md b/docs/deployment.md index 6c361efc3..a70f6c9ae 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -172,9 +172,11 @@ curl -N http://127.0.0.1:3000/chat -H 'Content-Type: application/json' \ ### Auth Set `LOUSHO_API_TOKEN` (on the Worker target, as a secret: `npx wrangler secret -put LOUSHO_API_TOKEN`) and every route except `/health` requires -`Authorization: Bearer `; anything else gets `401` with a JSON error -(the token is compared in constant time). Without it the server is open: that +put LOUSHO_API_TOKEN`) and the `/chat` routes and any channel without its +own `verify` require `Authorization: Bearer `; anything else gets `401` +with a JSON error (the token is compared in constant time). `/health` is +open, and channels that authenticate themselves (a `verify` function, or a +platform signature check such as Slack's) are not gated by the token. Without it the server is open: that is fine on `127.0.0.1`, but **treat the token as required for anything that is not on localhost** (the server logs a warning when it listens on another interface without one, and the `docker` image listens on all interfaces). diff --git a/llms-full.txt b/llms-full.txt index 75415ce30..7f6e5d675 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4034,21 +4034,32 @@ import * as http from 'node:http'; import { createAgent, createMockProvider, httpChannel, mountChannels } from '@lousho/build-ai-agent'; const agent = createAgent({ instructions: 'You are helpful.', provider: createMockProvider() }); -const channels = mountChannels(agent, [httpChannel()]); +const channels = mountChannels(agent, [ + httpChannel({ verify: async (req) => req.headers.authorization === `Bearer ${process.env.API_TOKEN}` }), +]); const server = http.createServer((req, res) => { void channels(req, res).then((handled) => { if (!handled) res.writeHead(404).end(); }); }); -server.listen(3000); +server.listen(3000, '127.0.0.1'); ``` ```bash -curl -s localhost:3000/channels/http -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}' +curl -s localhost:3000/channels/http -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' -d '{"sessionKey":"user-42","input":"Hi, I am Ali"}' # {"sessionId":"http_user-42-...","text":"...","finishReason":"stop"} ``` +### Securing the http channel + +Sessions are keyed by the `sessionKey` the caller sends, so an `httpChannel()` +without `verify` lets anyone who can reach the port read or continue another +user's conversation by guessing a key. It logs a warning once when created. +Always pass `verify` (a bearer-token check, as above, or your own session +check), bind to `127.0.0.1` unless a proxy or firewall fronts it, and derive +`sessionKey` from the authenticated user rather than trusting the client. + The handler resolves `true` when it served the request and `false` (nothing written) for any other route, like the `/chat` routes of `lousho dev`, so you can mount it next to your own routes. @@ -7193,9 +7204,11 @@ curl -N http://127.0.0.1:3000/chat -H 'Content-Type: application/json' \ ### Auth Set `LOUSHO_API_TOKEN` (on the Worker target, as a secret: `npx wrangler secret -put LOUSHO_API_TOKEN`) and every route except `/health` requires -`Authorization: Bearer `; anything else gets `401` with a JSON error -(the token is compared in constant time). Without it the server is open: that +put LOUSHO_API_TOKEN`) and the `/chat` routes and any channel without its +own `verify` require `Authorization: Bearer `; anything else gets `401` +with a JSON error (the token is compared in constant time). `/health` is +open, and channels that authenticate themselves (a `verify` function, or a +platform signature check such as Slack's) are not gated by the token. Without it the server is open: that is fine on `127.0.0.1`, but **treat the token as required for anything that is not on localhost** (the server logs a warning when it listens on another interface without one, and the `docker` image listens on all interfaces). diff --git a/src/channels/httpChannel.test.ts b/src/channels/httpChannel.test.ts new file mode 100644 index 000000000..89b98ccb3 --- /dev/null +++ b/src/channels/httpChannel.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { httpChannel } from './httpChannel'; + +describe('httpChannel verify warning', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('warns exactly once per channel when no verify is given', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + httpChannel(); + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain('[lousho]'); + expect(message).toMatch(/unauthenticated/); + expect(message).toMatch(/sessionKey/); + expect(message).toMatch(/verify/); + }); + + it('does not warn when verify is given', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + httpChannel({ verify: async () => true }); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/httpChannel.ts b/src/channels/httpChannel.ts index 15aab8f3b..98e337873 100644 --- a/src/channels/httpChannel.ts +++ b/src/channels/httpChannel.ts @@ -14,6 +14,8 @@ export interface HttpChannelOptions { * every request that is not a browser's cross-site one (`403`) and has a * JSON `Content-Type` (else `415`), so a web page cannot post to it with a * form; under `lousho dev` its `Host` is checked like the dev routes'. + * Omitting it logs a one-time warning: sessions are keyed by the + * caller-chosen `sessionKey`, so an unauthenticated channel exposes them. */ verify?: (req: ChannelRequest) => Promise; } @@ -34,6 +36,13 @@ export interface HttpChannelOptions { * ``` */ export function httpChannel(options: HttpChannelOptions = {}): Channel { + if (!options.verify) { + console.warn( + '[lousho] httpChannel() has no `verify`: the channel is unauthenticated, and sessions are keyed by the caller-chosen `sessionKey`, ' + + "so anyone who can reach it can read or continue another user's conversation by guessing a key. " + + 'Pass `verify: async (req) => req.headers.authorization === `Bearer ${token}`` (see docs/channels.md#securing-the-http-channel).' + ); + } return defineChannel({ name: options.name ?? 'http', verify: options.verify,