fix(users): use public site for admin password links

Use the browser origin when APP_URL points to a local address so setup links work through a public proxy. Preserve public canonical URLs and HTTPS.

Closes #1548
This commit is contained in:
neon
2026-09-26 00:26:26 +00:00
parent 4b90dc7ddd
commit fa36cb2edd
4 changed files with 103 additions and 3 deletions
+5 -3
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { onUnmounted, ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X } from '@lucide/vue'
import { copyToClipboard } from '@/lib/clipboard'
import { resolveResetLinkUrl } from './lib/reset-link'
const { t } = useI18n()
const props = defineProps<{ resetUrl: string }>()
const emit = defineEmits<{ close: [] }>()
const displayedUrl = computed(() => resolveResetLinkUrl(props.resetUrl, window.location.origin))
const copied = ref(false)
const copyFailed = ref(false)
@@ -46,7 +48,7 @@ function handleClose() {
}
function handleCopy() {
void copy(props.resetUrl)
void copy(displayedUrl.value)
}
</script>
@@ -64,7 +66,7 @@ function handleCopy() {
<div class="flex gap-2">
<input
:value="resetUrl"
:value="displayedUrl"
readonly
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-xs text-foreground focus:outline-none overflow-x-auto"
/>
@@ -3,15 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ResetLinkModal from '../ResetLinkModal.vue'
const copyToClipboardMock = vi.hoisted(() => vi.fn<(text: string) => Promise<boolean>>())
const resolveResetLinkUrlMock = vi.hoisted(() => vi.fn<(resetUrl: string, currentOrigin: string) => string>())
vi.mock('@/lib/clipboard', () => ({
copyToClipboard: copyToClipboardMock,
}))
vi.mock('../lib/reset-link', () => ({ resolveResetLinkUrl: resolveResetLinkUrlMock }))
describe('ResetLinkModal', () => {
beforeEach(() => {
vi.useFakeTimers()
copyToClipboardMock.mockResolvedValue(true)
resolveResetLinkUrlMock.mockImplementation((resetUrl) => resetUrl)
})
afterEach(() => {
@@ -55,4 +58,20 @@ describe('ResetLinkModal', () => {
expect(wrapper.text()).toContain('Copy')
expect(wrapper.text()).not.toContain('Copy failed')
})
it('displays and copies the resolved public URL', async () => {
resolveResetLinkUrlMock.mockReturnValue('https://books.example.test/reset-password?token=abc')
const wrapper = mount(ResetLinkModal, {
props: { resetUrl: 'http://172.18.0.4:3000/reset-password?token=abc' },
})
expect(resolveResetLinkUrlMock).toHaveBeenCalledWith('http://172.18.0.4:3000/reset-password?token=abc', window.location.origin)
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('https://books.example.test/reset-password?token=abc')
await wrapper
.findAll('button')
.find((button) => button.text() === 'Copy')
?.trigger('click')
expect(copyToClipboardMock).toHaveBeenCalledWith('https://books.example.test/reset-password?token=abc')
})
})
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { resolveResetLinkUrl } from '../reset-link'
describe('resolveResetLinkUrl', () => {
const publicOrigin = 'https://books.example.test'
it.each([
'http://172.18.0.4:3000',
'http://10.0.0.5:3000',
'http://192.168.1.5:3000',
'http://127.0.0.1:3000',
'http://localhost:3000',
'http://[::1]:3000',
'http://[fd12::1]:3000',
])('uses the public browser origin when APP_URL is local: %s', (configuredOrigin) => {
const resetUrl = `${configuredOrigin}/reset-password?token=a%2Bb%3Dc&next=1`
expect(resolveResetLinkUrl(resetUrl, publicOrigin)).toBe(`${publicOrigin}/reset-password?token=a%2Bb%3Dc&next=1`)
})
it('preserves a public configured URL when the admin uses a local address', () => {
const resetUrl = 'https://books.example.test/reset-password?token=abc'
expect(resolveResetLinkUrl(resetUrl, 'http://192.168.1.5:3000')).toBe(resetUrl)
})
it('preserves a different public canonical domain', () => {
const resetUrl = 'https://fcbooks.example.test/reset-password?token=abc'
expect(resolveResetLinkUrl(resetUrl, publicOrigin)).toBe(resetUrl)
})
it('preserves a local link when both addresses are local', () => {
const resetUrl = 'http://172.18.0.4:3000/reset-password?token=abc'
expect(resolveResetLinkUrl(resetUrl, 'http://192.168.1.5:3000')).toBe(resetUrl)
})
it('does not downgrade a secure reset link to HTTP', () => {
const resetUrl = 'https://localhost:3000/reset-password?token=abc'
expect(resolveResetLinkUrl(resetUrl, 'http://books.example.test')).toBe(resetUrl)
})
it('preserves malformed and unsupported URLs', () => {
expect(resolveResetLinkUrl('not a URL', publicOrigin)).toBe('not a URL')
expect(resolveResetLinkUrl('javascript:alert(1)', publicOrigin)).toBe('javascript:alert(1)')
})
})
@@ -0,0 +1,35 @@
function isLocalHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true
const parts = host.split('.')
if (parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) {
const [first = 0, second = 0] = parts.map(Number)
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
)
}
return host === '::1' || (host.includes(':') && (/^f[cd][0-9a-f]{2}:/.test(host) || /^fe[89ab][0-9a-f]:/.test(host)))
}
export function resolveResetLinkUrl(resetUrl: string, currentOrigin: string): string {
try {
const configured = new URL(resetUrl)
const current = new URL(currentOrigin)
if (!['http:', 'https:'].includes(configured.protocol) || !['http:', 'https:'].includes(current.protocol)) return resetUrl
if (configured.protocol === 'https:' && current.protocol !== 'https:') return resetUrl
// Keep the configured canonical URL unless it is local and the admin is using a public site.
if (!isLocalHost(configured.hostname) || isLocalHost(current.hostname)) return resetUrl
return `${current.origin}${configured.pathname}${configured.search}${configured.hash}`
} catch {
return resetUrl
}
}