security cleanup, fix turnstile

This commit is contained in:
2026-05-28 16:48:06 -04:00
parent b7187721db
commit d48bbc0fc3
14 changed files with 318 additions and 189 deletions

View File

@@ -1173,10 +1173,10 @@ export const authRouter = createTRPCRouter({
});
}
// Generate 6-digit code
const loginCode = Math.floor(
100000 + Math.random() * 900000
).toString();
// Generate cryptographically secure 6-digit code (p8-010)
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
const loginCode = (100000 + (randomBytes[0] % 900000)).toString();
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({

View File

@@ -372,7 +372,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable(),
banner_photo: z.string().nullable(),
published: z.boolean(),
tags: z.array(z.string()).nullable(),
tags: z.array(z.string().max(50).trim()).nullable(),
author_id: z.string()
})
)
@@ -405,12 +405,13 @@ export const databaseRouter = createTRPCRouter({
const results = await conn.execute({ sql: query, args: params });
if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map(
(tag) => `("${tag}", ${results.lastInsertRowid})`
);
tagQuery += values.join(", ");
await conn.execute(tagQuery);
const validTags = input.tags.filter((t) => t.length > 0);
for (const tag of validTags) {
await conn.execute({
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
args: [tag, results.lastInsertRowid]
});
}
}
await cache.deleteByPrefix("blog-");
@@ -434,7 +435,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable().optional(),
banner_photo: z.string().nullable().optional(),
published: z.boolean().nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
tags: z.array(z.string().max(50).trim()).nullable().optional(),
author_id: z.string()
})
)
@@ -523,10 +524,13 @@ export const databaseRouter = createTRPCRouter({
});
if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map((tag) => `("${tag}", ${input.id})`);
tagQuery += values.join(", ");
await conn.execute(tagQuery);
const validTags = input.tags.filter((t) => t.length > 0);
for (const tag of validTags) {
await conn.execute({
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
args: [tag, input.id]
});
}
}
await cache.deleteByPrefix("blog-");

View File

@@ -10,7 +10,7 @@ import {
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { SignJWT, jwtVerify } from "jose";
import { SignJWT, jwtVerify, importJWK } from "jose";
import { LibsqlError } from "@libsql/client/web";
import { createClient as createAPIClient } from "@tursodatabase/api";
@@ -354,11 +354,68 @@ export const lineageAuthRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email().optional(),
userString: z.string(),
idToken: z.string(),
})
)
.mutation(async ({ input }) => {
const { email, userString } = input;
const { email } = input;
// Verify Apple ID token signature using JWKS
const appleKeysResponse = await fetch(
"https://appleid.apple.com/auth/keys"
);
if (!appleKeysResponse.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Apple public keys",
});
}
const appleKeys = (await appleKeysResponse.json()) as {
keys: Array<{
kty: string;
kid: string;
use: string;
alg: string;
n: string;
e: string;
}>;
};
// Decode JWT header to find matching key
const [headerB64] = input.idToken.split(".");
if (!headerB64) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple ID token format",
});
}
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
const header = JSON.parse(headerJson) as { kid: string };
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
if (!jwk) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Apple public key not found",
});
}
const publicKey = await importJWK(jwk, "RS256");
const jwtOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: ["RS256"],
issuer: "https://appleid.apple.com",
};
if (env.APPLE_CLIENT_ID) {
jwtOptions.audience = env.APPLE_CLIENT_ID;
}
const { payload: tokenPayload } = await jwtVerify(
input.idToken,
publicKey,
jwtOptions
);
// Use verified Apple user ID from token (not from user input)
const userString = tokenPayload.sub as string;
let dbName;
let dbToken;

View File

@@ -6,8 +6,6 @@ import {
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { OAuth2Client } from "google-auth-library";
import { jwtVerify } from "jose";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
import {
fetchWithTimeout,
@@ -18,84 +16,8 @@ import {
} from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({
credentials: publicProcedure
.input(
z.object({
email: z.string().email(),
provider: z.enum(["email", "google", "apple"]),
authToken: z.string()
})
)
.mutation(async ({ input }) => {
const { email, provider, authToken } = input;
try {
let valid_request = false;
if (provider === "email") {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(authToken, secret);
if (payload.email === email) {
valid_request = true;
}
} else if (provider === "google") {
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
if (!CLIENT_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Google client ID not configured"
});
}
const client = new OAuth2Client(CLIENT_ID);
const ticket = await client.verifyIdToken({
idToken: authToken,
audience: CLIENT_ID
});
if (ticket.getPayload()?.email === email) {
valid_request = true;
}
} else {
const conn = LineageConnectionFactory();
const query = "SELECT * FROM User WHERE apple_user_string = ?";
const res = await conn.execute({ sql: query, args: [authToken] });
if (res.rows.length > 0 && res.rows[0].email === email) {
valid_request = true;
}
}
if (valid_request) {
const conn = LineageConnectionFactory();
const query = "SELECT * FROM User WHERE email = ? LIMIT 1";
const params = [email];
const res = await conn.execute({ sql: query, args: params });
if (res.rows.length === 1) {
const user = res.rows[0];
return {
success: true,
db_name: user.database_name as string,
db_token: user.database_token as string
};
}
throw new TRPCError({
code: "NOT_FOUND",
message: "No user found"
});
} else {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed"
});
}
}),
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
// Database access should be proxied through tRPC server-side procedures.
deletionInit: publicProcedure
.input(
@@ -155,6 +77,14 @@ export const lineageDatabaseRouter = createTRPCRouter({
if (skip_cron) {
if (send_dump_target) {
// Validate dump target matches the authenticated user's email (p8-005)
if (send_dump_target !== email) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Dump target must match account email"
});
}
const dumpRes = await dumpAndSendDB({
dbName: db_name,
dbToken: db_token,

View File

@@ -189,7 +189,7 @@ export const miscRouter = createTRPCRouter({
z.object({
key: z.string(),
newAttachmentString: z.string(),
type: z.string(),
type: z.enum(["Post", "Comment", "User"]),
id: z.number()
})
)
@@ -214,6 +214,7 @@ export const miscRouter = createTRPCRouter({
const res = await client.send(command);
const conn = ConnectionFactory();
// input.type is validated by z.enum allowlist above — safe for identifier use
const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`;
await conn.execute({
sql: query,
@@ -344,13 +345,22 @@ export const miscRouter = createTRPCRouter({
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
// HTML-escape user input to prevent HTML injection in email (p8-006)
const escapeHtml = (str: string) =>
str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${input.name}</div><div>Request Email: ${input.email}</div><div>Request Message: ${input.message}</div></body></html>`,
htmlContent: `<html><head></head><body><div>Request Name: ${escapeHtml(input.name)}</div><div>Request Email: ${escapeHtml(input.email)}</div><div>Request Message: ${escapeHtml(input.message)}</div></body></html>`,
subject: "freno.me Contact Request"
};

View File

@@ -1,6 +1,7 @@
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { jwtVerify, importJWK } from "jose";
import { NessaConnectionFactory } from "~/server/database";
import { cache } from "~/server/cache";
import { hashPassword, checkPasswordSafe } from "~/server/utils";
@@ -628,45 +629,30 @@ export const nessaDbRouter = createTRPCRouter({
const header = JSON.parse(headerJson) as { kid: string; alg: string };
// Find the matching key
const key = appleKeys.keys.find((k) => k.kid === header.kid);
if (!key) {
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
if (!jwk) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Apple public key not found"
});
}
// For simplicity, we'll decode the payload and verify basic claims
// In production, you should use a proper JWT library like jose to verify the signature
const [, payloadB64] = input.idToken.split(".");
if (!payloadB64) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple ID token format"
});
}
// Import the Apple JWK key for signature verification
const publicKey = await importJWK(jwk, "RS256");
const payloadJson = Buffer.from(payloadB64, "base64url").toString(
"utf8"
// Verify the Apple ID token signature and claims using jose
const jwtOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: ["RS256"],
issuer: "https://appleid.apple.com"
};
if (env.APPLE_CLIENT_ID) {
jwtOptions.audience = env.APPLE_CLIENT_ID;
}
const { payload: tokenPayload } = await jwtVerify(
input.idToken,
publicKey,
jwtOptions
);
const tokenPayload = JSON.parse(payloadJson) as AppleTokenPayload;
// Validate the token payload
if (tokenPayload.iss !== "https://appleid.apple.com") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token issuer"
});
}
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
if (tokenPayload.exp < now) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Token has expired"
});
}
// Apple user ID from token should match the one provided
if (tokenPayload.sub !== input.appleUserId) {
@@ -676,7 +662,7 @@ export const nessaDbRouter = createTRPCRouter({
});
}
const appleUserId = tokenPayload.sub;
const appleUserId = tokenPayload.sub as string;
// Apple only sends email on first sign-in, so use input.email if token doesn't have it
const email = tokenPayload.email ?? input.email;
const firstName = input.firstName ?? "Apple";