mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user