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
131 changes: 131 additions & 0 deletions app/pages/auth/forgot-password.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
definePageMeta({
layout: "auth",
middleware: ["guest"],
});

useSeoMeta({
title: "Forgot Password — Reqcore",
description: "Reset your Reqcore account password",
robots: "noindex, nofollow",
});

const email = ref("");
const error = ref("");
const success = ref(false);
const isLoading = ref(false);
const localePath = useLocalePath();
const { track } = useTrack();

onMounted(() => track("forgot_password_page_viewed"));

async function handleRequestReset() {
error.value = "";

if (!email.value) {
error.value = "Email is required.";
return;
}

isLoading.value = true;

try {
const result = await authClient.requestPasswordReset({
email: email.value,
redirectTo: `${window.location.origin}${localePath("/auth/reset-password")}`,
});

if (result.error) {
error.value =
result.error.message ?? "Failed to send reset email. Please try again.";
isLoading.value = false;
return;
}
} catch (e: unknown) {
error.value =
e instanceof Error ? e.message : "Failed to send reset email. Please try again.";
isLoading.value = false;
Comment on lines +38 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether forgot/reset flows emit user-specific messages that could enable enumeration.
# Expected: no "user not found / account does not exist" style messaging in this flow.

rg -n -C3 --iglob '*.{ts,js,vue}' \
'requestPasswordReset|forgot.?password|reset.?password|user not found|account.*exist|email.*exist|invalid email'

Repository: reqcore-inc/reqcore

Length of output: 6254


🏁 Script executed:

cat -n app/pages/auth/forgot-password.vue | sed -n '1,100p'

Repository: reqcore-inc/reqcore

Length of output: 3887


Behavioral enumeration risk: error vs. success states reveal account existence.

The code's control flow enables account enumeration regardless of message content. Errors (lines 38–42, 44–48) display an error panel and exit early, while success (lines 53–54) displays a success message. An attacker can infer whether an account exists by observing which UI state appears—this contradicts the anti-enumeration intent stated at line 53.

Treat all request outcomes (error, exception, success) as success to prevent behavioral leakage:

🔧 Proposed fix
 async function handleRequestReset() {
     error.value = "";

     if (!email.value) {
         error.value = "Email is required.";
         return;
     }

     isLoading.value = true;

     try {
         const result = await authClient.requestPasswordReset({
             email: email.value,
             redirectTo: `${window.location.origin}${localePath("/auth/reset-password")}`,
         });

         if (result.error) {
-            error.value =
-                result.error.message ?? "Failed to send reset email. Please try again.";
-            isLoading.value = false;
-            return;
+            // Treat all failures as success to prevent account enumeration.
         }
     } catch (e: unknown) {
-        error.value =
-            e instanceof Error ? e.message : "Failed to send reset email. Please try again.";
-        isLoading.value = false;
-        return;
+        // Treat all failures as success to prevent account enumeration.
     }

     track("forgot_password_submitted");

-    // Always show success to prevent email enumeration
     success.value = true;
     isLoading.value = false;
 }

Also check app/pages/auth/reset-password.vue (lines 58–69) for the same pattern.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (result.error) {
error.value =
result.error.message ?? "Failed to send reset email. Please try again.";
isLoading.value = false;
return;
}
} catch (e: unknown) {
error.value =
e instanceof Error ? e.message : "Failed to send reset email. Please try again.";
isLoading.value = false;
async function handleRequestReset() {
error.value = "";
if (!email.value) {
error.value = "Email is required.";
return;
}
isLoading.value = true;
try {
const result = await authClient.requestPasswordReset({
email: email.value,
redirectTo: `${window.location.origin}${localePath("/auth/reset-password")}`,
});
if (result.error) {
// Treat all failures as success to prevent account enumeration.
}
} catch (e: unknown) {
// Treat all failures as success to prevent account enumeration.
}
track("forgot_password_submitted");
success.value = true;
isLoading.value = false;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/auth/forgot-password.vue` around lines 38 - 47, The current flow
exposes whether an account exists because it shows different UI for errors vs.
success; update the forgot-password submission handler so that regardless of
result.error, thrown exceptions, or success you always clear isLoading.value and
set a generic success state/message (do not assign to error.value or return
early on result.error), logging/internal errors can be recorded internally but
the user-facing UI must always show the same success message; apply the
identical change to the reset-password handler (the code that sets error.value
and checks result.error/throws) so both handlers always present a uniform
success response to the client.

return;
}

track("forgot_password_submitted");

// Always show success to prevent email enumeration
success.value = true;
isLoading.value = false;
}
</script>

<template>
<div class="flex flex-col gap-4">
<h2
class="text-xl font-semibold text-center text-surface-900 dark:text-surface-100 mb-2"
>
Reset your password
</h2>

<template v-if="success">
<div
class="rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950 p-3 text-sm text-green-700 dark:text-green-400"
>
If an account with that email exists, we've sent a password reset link.
Please check your inbox and spam folder.
</div>

<p class="text-center text-sm text-surface-500 dark:text-surface-400 mt-2">
<NuxtLink
:to="$localePath('/auth/sign-in')"
class="text-brand-600 dark:text-brand-400 hover:underline"
>
Back to sign in
</NuxtLink>
</p>
</template>

<template v-else>
<p class="text-sm text-surface-500 dark:text-surface-400 text-center">
Enter your email address and we'll send you a link to reset your password.
</p>

<div
v-if="error"
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
>
{{ error }}
</div>
Comment on lines +90 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make error feedback screen-reader announceable.

The error block should be a live alert so failures are announced immediately.

♿ Proposed fix
             <div
                 v-if="error"
+                role="alert"
+                aria-live="assertive"
                 class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
             >
                 {{ error }}
             </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
v-if="error"
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
>
{{ error }}
</div>
<div
v-if="error"
role="alert"
aria-live="assertive"
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
>
{{ error }}
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/auth/forgot-password.vue` around lines 90 - 95, The error message
container currently rendered when the reactive property "error" is set should be
made screen-reader announceable: update the div that displays {{ error }} to
include accessibility attributes (e.g., role="alert" and aria-live="assertive",
and optionally aria-atomic="true") so assistive technologies immediately
announce failures; keep the conditional v-if="error" and the existing
styling/classes intact while adding these attributes to the error-rendering div.


<form class="flex flex-col gap-4" @submit.prevent="handleRequestReset">
<label
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
>
<span>Email</span>
<input
v-model="email"
type="email"
autocomplete="email"
required
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
/>
</label>

<button
type="submit"
:disabled="isLoading"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? "Sending…" : "Send reset link" }}
</button>
</form>

<p class="text-center text-sm text-surface-500 dark:text-surface-400">
Remember your password?
<NuxtLink
:to="$localePath('/auth/sign-in')"
class="text-brand-600 dark:text-brand-400 hover:underline"
>
Sign in
</NuxtLink>
</p>
</template>
</div>
</template>
173 changes: 173 additions & 0 deletions app/pages/auth/reset-password.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<script setup lang="ts">
definePageMeta({
layout: "auth",
middleware: ["guest"],
});

useSeoMeta({
title: "Reset Password — Reqcore",
description: "Set a new password for your Reqcore account",
robots: "noindex, nofollow",
});

const route = useRoute();
const newPassword = ref("");
const confirmPassword = ref("");
const error = ref("");
const success = ref(false);
const isLoading = ref(false);
const localePath = useLocalePath();
const { track } = useTrack();

const token = computed(() => route.query.token as string | undefined);
const tokenError = computed(() => route.query.error as string | undefined);

onMounted(() => track("reset_password_page_viewed"));

async function handleResetPassword() {
error.value = "";

if (!token.value) {
error.value = "Invalid or missing reset token. Please request a new password reset link.";
return;
}

if (!newPassword.value) {
error.value = "Password is required.";
return;
}

if (newPassword.value.length < 8) {
error.value = "Password must be at least 8 characters.";
return;
}

if (newPassword.value !== confirmPassword.value) {
error.value = "Passwords do not match.";
return;
}

isLoading.value = true;

try {
const result = await authClient.resetPassword({
newPassword: newPassword.value,
token: token.value,
});

if (result.error) {
error.value =
result.error.message ?? "Failed to reset password. The link may have expired.";
isLoading.value = false;
return;
}
} catch (e: unknown) {
error.value =
e instanceof Error ? e.message : "Failed to reset password. Please try again.";
isLoading.value = false;
return;
}

track("reset_password_completed");
success.value = true;
isLoading.value = false;
}
</script>

<template>
<div class="flex flex-col gap-4">
<h2
class="text-xl font-semibold text-center text-surface-900 dark:text-surface-100 mb-2"
>
Set new password
</h2>

<template v-if="success">
<div
class="rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950 p-3 text-sm text-green-700 dark:text-green-400"
>
Your password has been reset successfully.
</div>

<NuxtLink
:to="$localePath('/auth/sign-in')"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 transition-colors text-center block"
>
Sign in with new password
</NuxtLink>
</template>

<template v-else-if="tokenError || !token">
<div
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
>
{{ tokenError === 'INVALID_TOKEN'
? "This password reset link is invalid or has expired."
: "Invalid password reset link. Please request a new one." }}
</div>

<NuxtLink
:to="$localePath('/auth/forgot-password')"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 transition-colors text-center block"
>
Request new reset link
</NuxtLink>
</template>

<template v-else>
<div
v-if="error"
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
>
{{ error }}
</div>

<form class="flex flex-col gap-4" @submit.prevent="handleResetPassword">
<label
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
>
<span>New password</span>
<input
v-model="newPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
/>
</label>

<label
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
>
<span>Confirm new password</span>
<input
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
/>
</label>

<button
type="submit"
:disabled="isLoading"
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{{ isLoading ? "Resetting…" : "Reset password" }}
</button>
</form>

<p class="text-center text-sm text-surface-500 dark:text-surface-400">
<NuxtLink
:to="$localePath('/auth/sign-in')"
class="text-brand-600 dark:text-brand-400 hover:underline"
>
Back to sign in
</NuxtLink>
</p>
</template>
</div>
</template>
9 changes: 9 additions & 0 deletions app/pages/auth/sign-in.vue
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,15 @@ async function handleSocialSignIn(providerId: string) {
/>
</label>

<div class="flex justify-end -mt-2">
<NuxtLink
:to="$localePath('/auth/forgot-password')"
class="text-sm text-brand-600 dark:text-brand-400 hover:underline"
>
Forgot password?
</NuxtLink>
</div>

<button
type="submit"
:disabled="isLoading"
Expand Down
1 change: 0 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,6 @@ export default defineNuxtConfig({
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-XSS-Protection": "1; mode=block",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security":
"max-age=63072000; includeSubDomains; preload",
Expand Down
45 changes: 41 additions & 4 deletions server/api/sso/providers.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,49 @@ import { eq, and, ne } from 'drizzle-orm'
import { ssoProvider } from '~~/server/database/schema'
import { prefetchOidcEndpointOrigins } from '~~/server/utils/auth'

// Hostname/IP ranges that must never be contacted server-side (SSRF prevention)
const BLOCKED_ISSUER_HOSTNAMES = new Set([
'localhost',
'169.254.169.254', // AWS / Azure / DigitalOcean IMDS
'metadata.google.internal', // GCP IMDS
'metadata.internal',
'instance-data',
])

function isBlockedIssuerUrl(url: string): boolean {
let hostname: string
try {
hostname = new URL(url).hostname.toLowerCase()
} catch {
return true
}
if (BLOCKED_ISSUER_HOSTNAMES.has(hostname)) return true
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 || a === 0) return true
if (a === 10) return true
if (a === 172 && b >= 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)',
Expand Down
Loading
Loading