security cleanup, fix turnstile
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user