fix: harden security post-review (JWT exposure, rate limiter, token expiry, timing)

- Raw JWT never sent to browser: page.tsx uses peekLinkingToken (read-only),
  client sends opaque linkingId, API calls retrieveLinkingToken server-side
- link-account rate limiter now uses isRateLimited/registerFailedAttempt/
  resetAttempts correctly (count only failures, reset on success)
- linking_tokens gains expiresAt column (indexed) + opportunistic expiry
  purge on insert to prevent unbounded table growth
- secureTokenCompare fixed: pad+slice to expected length so timing is
  constant regardless of submitted token length (no length leak)
- autoLinkOAuth uses config.oauth.allowAutoLinking (boolean) instead of
  process.env truthy check that mishandles OAUTH_ALLOW_AUTO_LINKING=false
- Add Permissions-Policy header; restore X-Frame-Options for legacy UAs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-25 20:58:21 +01:00
parent b2238f3101
commit 75044c8d9b
8 changed files with 82 additions and 37 deletions
+2 -1
View File
@@ -182,5 +182,6 @@ export const auditEvents = sqliteTable("audit_events", {
export const linkingTokens = sqliteTable("linking_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull(),
createdAt: text("created_at").notNull()
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
});
+35 -7
View File
@@ -5,7 +5,7 @@ import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
import db from "../db";
import { users, linkingTokens } from "../db/schema";
import { eq } from "drizzle-orm";
import { eq, lt } from "drizzle-orm";
import { nowIso } from "../db";
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
@@ -124,25 +124,53 @@ export async function verifyLinkingToken(token: string): Promise<LinkingTokenPay
}
/**
* Store a linking JWT in the DB and return an opaque 64-char hex ID
* Store a linking JWT in the DB and return an opaque 64-char hex ID.
* Expired rows are purged on each insert to prevent unbounded table growth.
*/
export async function storeLinkingToken(token: string): Promise<string> {
const id = randomBytes(32).toString("hex");
const now = nowIso();
const expiresAt = new Date(Date.now() + LINKING_TOKEN_EXPIRY * 1000).toISOString();
// Purge expired tokens opportunistically
await db.delete(linkingTokens).where(lt(linkingTokens.expiresAt, now));
await db.insert(linkingTokens).values({
id,
token,
createdAt: nowIso()
createdAt: now,
expiresAt
});
return id;
}
/**
* Peek at a linking token by its opaque ID without consuming (deleting) it.
* Used by the link-account page to decode display info (provider, email) while
* keeping the token available for the subsequent API call.
* Returns null if the ID is not found or the token is expired.
*/
export async function peekLinkingToken(id: string): Promise<string | null> {
const now = nowIso();
const rows = await db.select().from(linkingTokens)
.where(eq(linkingTokens.id, id))
.limit(1);
if (rows.length === 0 || rows[0].expiresAt < now) {
return null;
}
return rows[0].token;
}
/**
* Retrieve and delete a linking token by its opaque ID (one-time use).
* Returns null if the ID is not found.
* Returns null if the ID is not found or the token is expired.
*/
export async function retrieveLinkingToken(id: string): Promise<string | null> {
const rows = await db.select().from(linkingTokens).where(eq(linkingTokens.id, id)).limit(1);
if (rows.length === 0) {
const now = nowIso();
const rows = await db.select().from(linkingTokens)
.where(eq(linkingTokens.id, id))
.limit(1);
if (rows.length === 0 || rows[0].expiresAt < now) {
return null;
}
const { token } = rows[0];
@@ -199,7 +227,7 @@ export async function autoLinkOAuth(
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
// This check is bypassed when called from the authenticated linking flow
if (user.password_hash && !process.env.OAUTH_ALLOW_AUTO_LINKING) {
if (user.password_hash && !config.oauth.allowAutoLinking) {
return false;
}