Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Postgres connection string used by lib/db.ts.
# TLS certificates are verified against the system trust store by default.
# Managed providers normally work with sslmode=require as written below.
# Postgres connection string used by lib/db.ts. sslmode follows libpq semantics:
# require/prefer encrypt without verifying the server certificate (fine for a
# self-signed Postgres on the same host); use verify-full when the database is
# on another host to verify against the system trust store or DATABASE_CA_CERT.
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DBNAME?sslmode=require

# Optional. CA certificate used to verify the Postgres server's TLS identity —
# either the PEM contents inline or a path to a .pem/.crt file. When set, the
# connection verifies the server against that CA. When unset, system CAs apply.
# connection always verifies the server against that CA. When unset,
# verification is governed by sslmode.
DATABASE_CA_CERT=

# Local development only. Set this alongside sslmode=no-verify when a loopback
Expand All @@ -25,8 +27,9 @@ AUTH_GOOGLE_SECRET=
# Optional. Anthropic key for AI note titles/summaries (Claude Haiku). If unset,
# Keep falls back to local zero-token title inference. ANTHROPIC_API_KEY also works.
ANTHROPIC_KEY=
# Explicit privacy opt-in: when true, note text is sent to Anthropic for metadata.
AI_METADATA_ENABLED=false
# A configured key sends note text to Anthropic for titles/summaries. Set to
# false to keep note text local while leaving the key in place.
AI_METADATA_ENABLED=
# Override the model (default: claude-haiku-4-5-20251001).
ANTHROPIC_MODEL=

Expand Down
11 changes: 7 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ not put credentials, private notes, or an unpatched exploit in a public issue.
## Deployment checklist

- Set a strong `AUTH_SECRET` and the exact HTTPS `AUTH_URL`.
- Keep Postgres on a private network. TLS verifies system CAs by default; use
`DATABASE_CA_CERT` for a private CA. `DATABASE_TLS_INSECURE=true` is only for
- Keep Postgres on a private network. `sslmode=require` encrypts but does not
authenticate the server — acceptable for a same-host database; use
`sslmode=verify-full` (with `DATABASE_CA_CERT` for a private CA) whenever the
database is on another host. `DATABASE_TLS_INSECURE=true` is only for
loopback development with `sslmode=no-verify`.
- Keep attachment storage private. S3 deployments should enable Block Public
Access and grant the application only object read/write/delete permissions
under the `keep/` prefix.
- Set `DEPLOY_KNOWN_HOSTS` to a separately verified SSH host-key line. Deployment
fails closed when the host key changes.
- Leave `AI_METADATA_ENABLED=false` unless sending authenticated note text to
Anthropic is acceptable and disclosed to users. Provider and account budgets
- A configured `ANTHROPIC_KEY` sends authenticated note text to Anthropic for
titles/summaries; set `AI_METADATA_ENABLED=false` to keep note text local, and
disclose the model use to users when it stays on. Provider and account budgets
should also be configured outside the application.
- Back up Postgres and object storage with encryption at rest and tested restore
procedures. Restrict production logs and database access to operators who need
Expand Down
14 changes: 14 additions & 0 deletions __tests__/appUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ describe("canonical application origin", () => {
});
expect(isSameOriginMutation(request)).toBe(false);
});

it("trusts the browser's same-origin assertion behind a reverse proxy", () => {
// Public Origin header, internal listen URL — the shape every production
// request has behind Caddy. Sec-Fetch-Site must decide, not a comparison
// against the internal origin.
const request = new Request("http://localhost:3000/api/auth/revoke", {
method: "POST",
headers: {
origin: "https://keeptxt.com",
"sec-fetch-site": "same-origin",
},
});
expect(isSameOriginMutation(request)).toBe(true);
});
});
6 changes: 4 additions & 2 deletions __tests__/dbCaCert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ afterEach(() => {
});

describe("databaseSslOptions", () => {
it("verifies certificates by default for TLS connections", () => {
expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: true });
it("follows libpq verification semantics per sslmode", () => {
expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: false });
expect(databaseSslOptions("prefer")).toEqual({ rejectUnauthorized: false });
expect(databaseSslOptions("verify-ca")).toEqual({ rejectUnauthorized: true });
expect(databaseSslOptions("verify-full")).toEqual({ rejectUnauthorized: true });
});

Expand Down
19 changes: 18 additions & 1 deletion __tests__/noteValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { isNoteColor } from "@/lib/noteColors";
import { tagsInvalid, MAX_TAGS, MAX_TAG_LEN } from "@/lib/noteLimits";
import {
tagsInvalid,
MAX_NOTE_BODY,
MAX_NOTE_REQUEST_BYTES,
MAX_TAGS,
MAX_TAG_LEN,
} from "@/lib/noteLimits";

describe("isNoteColor", () => {
it("accepts palette keys and rejects anything else", () => {
Expand All @@ -12,6 +18,17 @@ describe("isNoteColor", () => {
});
});

describe("MAX_NOTE_REQUEST_BYTES", () => {
it("admits a maximum-length note in its worst-case JSON encoding", () => {
// Control characters JSON-escape to \uXXXX — 6 bytes per UTF-16 char, the
// densest encoding a valid MAX_NOTE_BODY-char body can reach.
const body = String.fromCharCode(1).repeat(MAX_NOTE_BODY);
expect(Buffer.byteLength(JSON.stringify({ body }))).toBeLessThanOrEqual(
MAX_NOTE_REQUEST_BYTES,
);
});
});

describe("tagsInvalid", () => {
it("accepts a normal tag array", () => {
expect(tagsInvalid(["work", "ideas"])).toBe(false);
Expand Down
11 changes: 6 additions & 5 deletions __tests__/offlineDb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,24 @@ function note(id: string, body: string): Note {
}

describe("account-scoped offline storage", () => {
it("discards ownerless legacy pending creates instead of assigning them to an account", async () => {
it("migrates un-replayed legacy pending ops into the first per-owner DB", async () => {
const legacy = await openDB("keep-offline", 1, {
upgrade(db) {
db.createObjectStore("notes", { keyPath: "id" });
db.createObjectStore("pending", { keyPath: "id" });
},
});
await legacy.put("pending", {
const op = {
id: "legacy-create",
type: "create",
noteId: "legacy-note",
payload: note("legacy-note", "another account's private draft"),
payload: note("legacy-note", "offline draft made before the per-owner split"),
createdAt: 1,
});
};
await legacy.put("pending", op);
legacy.close();

await expect(getPendingOps(`new-owner-${crypto.randomUUID()}`)).resolves.toEqual([]);
await expect(getPendingOps(`new-owner-${crypto.randomUUID()}`)).resolves.toEqual([op]);
});

it("never returns one account's cached notes to another account", async () => {
Expand Down
15 changes: 14 additions & 1 deletion __tests__/titleModelSecurity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ afterEach(() => {
});

describe("AI metadata privacy gate", () => {
it("does not send note text when explicit opt-in is absent", async () => {
it("keeps note text local when AI metadata is explicitly disabled", async () => {
process.env.AI_METADATA_ENABLED = "false";
process.env.ANTHROPIC_KEY = "test-key";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
Expand All @@ -19,6 +20,18 @@ describe("AI metadata privacy gate", () => {
expect(fetchMock).not.toHaveBeenCalled();
});

it("enables AI metadata from a configured key without an extra flag", async () => {
process.env.ANTHROPIC_KEY = "test-key";
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
content: [{ text: '"title":"Generated","summary":"Short"}' }],
}), { status: 200, headers: { "content-type": "application/json" } }));
vi.stubGlobal("fetch", fetchMock);

await expect(generateNoteMeta("some note body")).resolves.toMatchObject({
title: "Generated",
});
});

it("caps provider text at 8 KiB when enabled", async () => {
process.env.AI_METADATA_ENABLED = "true";
process.env.ANTHROPIC_KEY = "test-key";
Expand Down
2 changes: 1 addition & 1 deletion app/api/notes/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { internalError } from "@/lib/apiError";
import { isNoteColor } from "@/lib/noteColors";
import {
MAX_NOTE_BODY,
MAX_NOTE_REQUEST_BYTES,
MAX_NOTE_SUMMARY,
MAX_NOTE_TITLE,
tagsInvalid,
Expand All @@ -27,7 +28,6 @@ const ALLOWED = new Set([
"highlight",
"tags",
]);
const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY + 16 * 1024;

export async function PATCH(
req: Request,
Expand Down
2 changes: 1 addition & 1 deletion app/api/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { heuristicNoteMeta } from "@/lib/titleModel";
import { internalError } from "@/lib/apiError";
import {
MAX_NOTE_BODY,
MAX_NOTE_REQUEST_BYTES,
MAX_NOTE_SUMMARY,
MAX_NOTE_TITLE,
MAX_NOTES_PER_USER,
Expand All @@ -18,7 +19,6 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const CLIENT_NOTE_KEY = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY + 16 * 1024;

function noteIdForCreate(userId: string, requested: unknown) {
if (typeof requested !== "string") return newId();
Expand Down
57 changes: 45 additions & 12 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,62 @@ export async function POST(req: Request) {
const id = newId();
const path = `keep/${session.user.id}/${id}.${imageExtension(file.type)}`;
const client = await pool().connect();
// Storage keys freed by the orphan sweep; their objects are deleted only
// after the row deletions have committed.
let reclaimed: string[] = [];
let overQuota = false;
try {
await client.query("BEGIN");
await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [session.user.id]);
const usage = await client.query<{ bytes: string }>(
`SELECT coalesce(sum(size), 0)::text AS bytes FROM uploads WHERE user_id = $1`,
[session.user.id],
);
if (Number(usage.rows[0]?.bytes ?? 0) + file.size > MAX_USER_UPLOAD_BYTES) {
await client.query("ROLLBACK");
return NextResponse.json({ error: "Upload storage quota exceeded" }, { status: 413 });
const usedBytes = async () => {
const usage = await client.query<{ bytes: string }>(
`SELECT coalesce(sum(size), 0)::text AS bytes FROM uploads WHERE user_id = $1`,
[session.user.id],
);
return Number(usage.rows[0]?.bytes ?? 0);
};
if ((await usedBytes()) + file.size > MAX_USER_UPLOAD_BYTES) {
// Images edited out of every note body would otherwise hold quota
// forever; reclaim them before failing the upload. Uploads from the
// last minute are spared — their reference may still be sitting in an
// editor waiting on autosave.
const orphaned = await client.query<{ storage_key: string }>(
`DELETE FROM uploads u
WHERE u.user_id = $1
AND u.created_at < $2
AND NOT EXISTS (
SELECT 1 FROM notes n
WHERE n.user_id = $1
AND position('/api/uploads/' || u.id in n.body) > 0
)
RETURNING storage_key`,
[session.user.id, Date.now() - 60_000],
);
reclaimed = orphaned.rows.map((row) => row.storage_key);
overQuota = (await usedBytes()) + file.size > MAX_USER_UPLOAD_BYTES;
}
await client.query(
`INSERT INTO uploads (id, user_id, storage_key, content_type, size, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, session.user.id, path, file.type, file.size, Date.now()],
);
if (!overQuota) {
await client.query(
`INSERT INTO uploads (id, user_id, storage_key, content_type, size, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, session.user.id, path, file.type, file.size, Date.now()],
);
}
// Commit either way so a sweep that freed rows sticks even when the
// upload itself is still rejected.
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
} finally {
client.release();
}
for (const key of reclaimed) {
await deletePrivateFile(key).catch(() => {});
}
if (overQuota) {
return NextResponse.json({ error: "Upload storage quota exceeded" }, { status: 413 });
}
try {
await putPrivateFile(path, bytes, file.type);
} catch (err) {
Expand Down
7 changes: 6 additions & 1 deletion auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
);
const currentVersion = rows[0]?.session_version;
if (currentVersion === undefined) return null;
if (user || token.sessionVersion === undefined) {
if (user) {
token.sessionVersion = currentVersion;
} else if (token.sessionVersion !== currentVersion) {
// Tokens minted before session versioning carry no claim and fail this
// comparison too — grandfathering them would exempt every pre-deploy
// session from revocation forever (auth() in route handlers can't
// persist an adopted claim back into the cookie). Those users simply
// re-authenticate once.
return null;
}
return token;
Expand Down
19 changes: 16 additions & 3 deletions components/SignOutButton.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
"use client";

import { signOut } from "next-auth/react";
import { clearOwnerData } from "@/lib/offlineDb";
import { clearOwnerData, getPendingOps } from "@/lib/offlineDb";

export function SignOutButton({ ownerId }: { ownerId: string }) {
async function handleSignOut() {
// Queued offline edits die with clearOwnerData; make that an informed
// choice instead of silent loss.
const pending = await getPendingOps(ownerId).catch(() => []);
if (
pending.length > 0 &&
!window.confirm(
"Some note changes haven't synced yet and will be lost if you sign out now. Sign out anyway?",
)
) {
return;
}
await clearOwnerData(ownerId).catch(() => {});
try {
localStorage.setItem("keep.signout", JSON.stringify({ ownerId, at: Date.now() }));
localStorage.removeItem("keep.signout");
} catch {
// Storage may be disabled; server-side session revocation still proceeds.
// Storage may be disabled; this browser's session still ends below.
}
await fetch("/api/auth/revoke", { method: "POST" }).catch(() => null);
// Ends only this browser's session. Revoking every device (POST
// /api/auth/revoke) is a distinct, explicit action — a plain "Sign out"
// must not silently log out the user's phone and other browsers.
await signOut({ callbackUrl: "/signin" });
}

Expand Down
6 changes: 5 additions & 1 deletion lib/appUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ export function appOrigin(req: Request): string {
}

export function isSameOriginMutation(req: Request): boolean {
// Sec-Fetch-Site is browser-asserted and authoritative when present. Behind
// the reverse proxy the request URL is the internal listen origin, so the
// Origin header is only compared (against the configured public origin) for
// older browsers that don't send Sec-Fetch-Site.
const fetchSite = req.headers.get("sec-fetch-site");
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") return false;
if (fetchSite) return fetchSite === "same-origin" || fetchSite === "none";
const origin = req.headers.get("origin");
return !origin || origin === appOrigin(req);
}
10 changes: 6 additions & 4 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { logger } from "@/lib/logger";
// CA certificate for verifying the Postgres server's TLS cert. DATABASE_CA_CERT
// may be the PEM contents inline or a path to a .pem/.crt file. When set, the
// connection verifies the server identity (rejectUnauthorized: true); when
// unset we fall back to TLS without certificate verification for self-signed localhost.
// unset, verification follows the URL's sslmode (see databaseSslOptions).
export function caCert(): string | undefined {
const raw = process.env.DATABASE_CA_CERT;
if (!raw || !raw.trim()) return undefined;
Expand Down Expand Up @@ -71,9 +71,11 @@ export function databaseSslOptions(
"sslmode=no-verify requires DATABASE_TLS_INSECURE=true; never use it across a network",
);
}
return ca
? { ca, rejectUnauthorized: true }
: { rejectUnauthorized: !allowInsecure };
if (ca) return { ca, rejectUnauthorized: true };
// libpq semantics: require/prefer encrypt without authenticating the server
// (a self-signed same-host Postgres keeps working); verify-ca/verify-full —
// or a configured DATABASE_CA_CERT above — turn certificate verification on.
return { rejectUnauthorized: sslmode === "verify-ca" || sslmode === "verify-full" };
}

export function pool(): Pool {
Expand Down
6 changes: 5 additions & 1 deletion lib/noteLimits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Abuse caps shared by the create and update note routes. Body and tags are
// otherwise unbounded, which is a storage-exhaustion vector.
export const MAX_NOTE_BODY = 256 * 1024; // 256 KB — generous for a single note
export const MAX_NOTE_BODY = 256 * 1024; // 256K UTF-16 chars — generous for a single note
// Byte cap for the JSON request wrapping a note. MAX_NOTE_BODY counts UTF-16
// characters, and one character can JSON-encode to up to 6 UTF-8 bytes
// (\uXXXX); sizing for the worst case keeps large multibyte notes saveable.
export const MAX_NOTE_REQUEST_BYTES = MAX_NOTE_BODY * 6 + 16 * 1024;
export const MAX_NOTE_TITLE = 120;
export const MAX_NOTE_SUMMARY = 500;
export const MAX_TAGS = 50;
Expand Down
Loading
Loading