diff --git a/app/pages/auth/forgot-password.vue b/app/pages/auth/forgot-password.vue
new file mode 100644
index 0000000..b29325f
--- /dev/null
+++ b/app/pages/auth/forgot-password.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+ Reset your password
+
+
+
+
+ If an account with that email exists, we've sent a password reset link.
+ Please check your inbox and spam folder.
+
+
+
+
+ Back to sign in
+
+
+
+
+
+
+ Enter your email address and we'll send you a link to reset your password.
+
+
+
+ {{ error }}
+
+
+
+
+
+ Remember your password?
+
+ Sign in
+
+
+
+
+
diff --git a/app/pages/auth/reset-password.vue b/app/pages/auth/reset-password.vue
new file mode 100644
index 0000000..ef28cc9
--- /dev/null
+++ b/app/pages/auth/reset-password.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+ Set new password
+
+
+
+
+ Your password has been reset successfully.
+
+
+
+ Sign in with new password
+
+
+
+
+
+ {{ tokenError === 'INVALID_TOKEN'
+ ? "This password reset link is invalid or has expired."
+ : "Invalid password reset link. Please request a new one." }}
+
+
+
+ Request new reset link
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
diff --git a/app/pages/auth/sign-in.vue b/app/pages/auth/sign-in.vue
index 0f24f55..ac27bf5 100644
--- a/app/pages/auth/sign-in.vue
+++ b/app/pages/auth/sign-in.vue
@@ -322,6 +322,15 @@ async function handleSocialSignIn(providerId: string) {
/>
+
+
+ Forgot password?
+
+
+
= 16 && b <= 31) return true
+ if (a === 192 && b === 168) return true
+ if (a === 100 && b >= 64 && b <= 127) return true
+ if (a === 169 && b === 254) return true
+ }
+ if (hostname === '::1') return true
+ if (hostname.startsWith('fe80:')) return true
+ return false
+}
+
const registerSsoSchema = z.object({
providerId: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'Only lowercase alphanumeric and hyphens'),
- issuer: z.string().url().refine(
- (url) => url.startsWith('https://') || url.startsWith('http://'),
- 'Issuer URL must use HTTPS (or HTTP for local development)',
- ),
+ issuer: z.string().url()
+ .refine(
+ (url) => url.startsWith('https://') || url.startsWith('http://'),
+ 'Issuer URL must use HTTPS (or HTTP for local development)',
+ )
+ .refine(
+ (url) => !isBlockedIssuerUrl(url),
+ 'Issuer URL must not target internal or private network addresses',
+ ),
domain: z.string().min(1).max(253).regex(
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/,
'Must be a valid domain (e.g. company.com)',
diff --git a/server/utils/auth.ts b/server/utils/auth.ts
index 61051b4..6639ccd 100644
--- a/server/utils/auth.ts
+++ b/server/utils/auth.ts
@@ -10,6 +10,50 @@ import * as schema from "../database/schema";
type Auth = ReturnType;
let _auth: Auth | undefined;
+// ── SSRF blocklist ────────────────────────────────────────────────────────────
+// Prevent org admins from using SSO provider registration to probe the
+// internal network or cloud metadata services (OWASP A10 - SSRF).
+const BLOCKED_HOSTNAMES = new Set([
+ "localhost",
+ "169.254.169.254", // AWS / Azure / DigitalOcean IMDS
+ "metadata.google.internal", // GCP IMDS
+ "metadata.internal",
+ "instance-data", // older cloud-init
+])
+
+/**
+ * Returns true if the hostname resolves to a private, loopback, link-local,
+ * or well-known cloud metadata address that must not be contacted server-side.
+ */
+function isBlockedHost(urlString: string): boolean {
+ let hostname: string
+ try {
+ hostname = new URL(urlString).hostname.toLowerCase()
+ } catch {
+ return true // malformed URL → block
+ }
+ if (BLOCKED_HOSTNAMES.has(hostname)) return true
+
+ // IPv4 private / loopback ranges
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
+ if (ipv4) {
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]
+ if (a === 127) return true // 127.0.0.0/8 loopback
+ if (a === 0) return true // 0.0.0.0/8
+ if (a === 10) return true // 10.0.0.0/8 RFC 1918
+ if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 RFC 1918
+ if (a === 192 && b === 168) return true // 192.168.0.0/16 RFC 1918
+ if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 CGNAT
+ if (a === 169 && b === 254) return true // 169.254.0.0/16 link-local
+ }
+
+ // IPv6 loopback and link-local
+ if (hostname === "::1") return true
+ if (hostname.startsWith("fe80:") || hostname.startsWith("[fe80:")) return true
+
+ return false
+}
+
/**
* Fetch an OIDC discovery document and inject every endpoint origin into
* better-auth's live trusted-origins list so the SSO plugin trusts them
@@ -25,6 +69,14 @@ let _auth: Auth | undefined;
* Must be called **before** `auth.api.registerSSOProvider()`.
*/
export async function prefetchOidcEndpointOrigins(issuerUrl: string): Promise {
+ // SSRF guard — reject internal/private addresses before any network call
+ if (isBlockedHost(issuerUrl)) {
+ throw createError({
+ statusCode: 422,
+ statusMessage: "Issuer URL must not target internal or private network addresses.",
+ });
+ }
+
const discoveryUrl = issuerUrl.replace(/\/+$/, "") + "/.well-known/openid-configuration";
const res = await $fetch>(discoveryUrl, {
timeout: 10_000,
diff --git a/server/utils/resume-parser.ts b/server/utils/resume-parser.ts
index 4c4c652..1dc41f4 100644
--- a/server/utils/resume-parser.ts
+++ b/server/utils/resume-parser.ts
@@ -99,6 +99,8 @@ export async function parseDocument(
// ─── PDF Parser ───────────────────────────────────────────────────
async function parsePdf(buffer: Buffer): Promise {
+ if (buffer.length === 0) return null
+
// Polyfill browser globals before pdfjs-dist evaluates its module-level code
ensurePdfjsPolyfills()
const { PDFParse } = await import('pdf-parse')