feat(auth): Allow SSO login only for whitelisted addresses (localhost) (#5815)

## Summary

Hide Google/GitHub SSO login options when the UI is accessed from
**non‑local** addresses.
This PR also adds a **static whitelist** (editable in code) so we can
allow additional hosts if needed.

Default whitelisted addresses:

1. `localhost` and any subdomain: `*.localhost`
2. IPv4 loopback `127.0.0.0/8` (e.g., `127.x.y.z`)
4. IPv6 loopback `::1` (including equivalent textual forms such as
`::0001`)

## Changes

- **What**: 
* Add `src/utils/hostWhitelist.ts` with `normalizeHost` and
`isHostWhitelisted` helpers.
  * Update `SignInContent.vue` to **hide** SSO options when
`isHostWhitelisted(normalizeHost(window.location.hostname))` returns
`false`.
- **Breaking**:
* Users accessing from Runpod or other previously allowed **non‑local**
hosts will **lose** SSO login options.
If we need to keep SSO there, we should add those hosts to the whitelist
in `hostWhitelist.ts`.

## Review Focus

1. Verify that logging in from local addresses (`localhost`,
`*.localhost`, `127.0.0.1`, `::1`) **does not change** the current
behavior: SSO is visible.
2. Verify that from a **non‑local** address, SSO options are **not**
displayed.

## Screenshots (if applicable)

UI opened from `192.168.2.109` address:

<img width="500" height="990" alt="Screenshot From 2025-09-27 13-22-15"
src="https://github.com/user-attachments/assets/c97b10a1-b069-43e4-a26b-a71eeb228a51"
/>

UI opened from default `127.0.0.1` address(nothing changed):

<img width="462" height="955" alt="Screenshot From 2025-09-27 13-35-27"
src="https://github.com/user-attachments/assets/bb2bf21c-dc8d-49cb-b48e-8fc6e408023c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5815-feat-auth-Allow-SSO-login-only-for-whitelisted-addresses-localhost-27b6d73d365081ccbe84c034cf8e416d)
by [Unito](https://www.unito.io)
This commit is contained in:
Alexander Piskun
2025-10-02 09:09:11 +03:00
committed by GitHub
parent c662c77305
commit 9c97fb359d
3 changed files with 247 additions and 29 deletions

View File

@@ -45,37 +45,39 @@
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<!-- Social Login Buttons (hidden if host not whitelisted) -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<template v-if="ssoAllowed">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</template>
<Button
type="button"
@@ -149,6 +151,7 @@ import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
@@ -164,6 +167,7 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const toggleState = () => {
isSignIn.value = !isSignIn.value

View File

@@ -0,0 +1,91 @@
/**
* Whitelisting helper for enabling SSO on safe, local-only hosts.
*
* Built-ins (always allowed):
* • 'localhost' and any subdomain of '.localhost' (e.g., app.localhost)
* • IPv4 loopback 127.0.0.0/8 (e.g., 127.0.0.1, 127.1.2.3)
* • IPv6 loopback ::1 (supports compressed/expanded textual forms)
*
* No environment variables are used. To add more exact hostnames,
* edit HOST_WHITELIST below.
*/
const HOST_WHITELIST: string[] = ['localhost']
/** Normalize for comparison: lowercase, strip port/brackets, trim trailing dot. */
export function normalizeHost(input: string): string {
let h = (input || '').trim().toLowerCase()
// Trim a trailing dot: 'localhost.' -> 'localhost'
h = h.replace(/\.$/, '')
// Remove ':port' safely.
// Case 1: [IPv6]:port
const mBracket = h.match(/^\[([^\]]+)\]:(\d+)$/)
if (mBracket) {
h = mBracket[1] // keep only the host inside the brackets
} else {
// Case 2: hostname/IPv4:port (exactly one ':')
const mPort = h.match(/^([^:]+):(\d+)$/)
if (mPort) h = mPort[1]
}
// Strip any remaining brackets (e.g., '[::1]' -> '::1')
h = h.replace(/^\[|\]$/g, '')
return h
}
/** Public check used by the UI. */
export function isHostWhitelisted(rawHost: string): boolean {
const host = normalizeHost(rawHost)
if (isLocalhostLabel(host)) return true
if (isIPv4Loopback(host)) return true
if (isIPv6Loopback(host)) return true
const normalizedList = HOST_WHITELIST.map(normalizeHost)
return normalizedList.includes(host)
}
/* -------------------- Helpers -------------------- */
function isLocalhostLabel(h: string): boolean {
// 'localhost' and any subdomain (e.g., 'app.localhost')
return h === 'localhost' || h.endsWith('.localhost')
}
const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|0?\\d?\\d)'
const V4_LOOPBACK_RE = new RegExp(
'^127\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '\\.' + IPV4_OCTET + '$'
)
function isIPv4Loopback(h: string): boolean {
// 127/8 with strict 0255 octets (leading zeros allowed, e.g., 127.000.000.001)
return V4_LOOPBACK_RE.test(h)
}
// Fully expanded IPv6 loopback: 0:0:0:0:0:0:0:1 (allow leading zeros up to 4 chars)
const V6_FULL_LOOPBACK_RE = /^(?:0{1,4}:){7}0{0,3}1$/i
// Compressed IPv6 loopback forms around '::' with only zero groups before the final :1
// - Left side: zero groups separated by ':' (no trailing colon required)
// - Right side: zero groups each followed by ':' (so the final ':1' is provided by the pattern)
// The final group is exactly value 1, with up to 3 leading zeros (e.g., '0001').
const V6_COMPRESSED_LOOPBACK_RE =
/^((?:0{1,4}(?::0{1,4}){0,6})?)::((?:0{1,4}:){0,6})0{0,3}1$/i
function isIPv6Loopback(h: string): boolean {
// Exact full form: 0:0:0:0:0:0:0:1 (with up to 3 leading zeros on the final "1" group)
if (V6_FULL_LOOPBACK_RE.test(h)) return true
// Compressed forms that still equal ::1 (e.g., ::1, ::0001, 0:0::1, ::0:1, etc.)
const m = h.match(V6_COMPRESSED_LOOPBACK_RE)
if (!m) return false
// Count explicit zero groups on each side of '::' to ensure at least one group is compressed.
// (leftCount + rightCount) must be ≤ 6 so that the total expanded groups = 8.
const leftCount = m[1] ? m[1].match(/0{1,4}:/gi)?.length ?? 0 : 0
const rightCount = m[2] ? m[2].match(/0{1,4}:/gi)?.length ?? 0 : 0
// Require that at least one group was actually compressed: i.e., leftCount + rightCount ≤ 6.
return leftCount + rightCount <= 6
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
describe('hostWhitelist utils', () => {
describe('normalizeHost', () => {
it.each([
['LOCALHOST', 'localhost'],
['localhost.', 'localhost'], // trims trailing dot
['localhost:5173', 'localhost'], // strips :port
['127.0.0.1:5173', '127.0.0.1'], // strips :port
['[::1]:5173', '::1'], // strips brackets + :port
['[::1]', '::1'], // strips brackets
['::1', '::1'], // leaves plain IPv6
[' [::1] ', '::1'], // trims whitespace
['APP.LOCALHOST', 'app.localhost'], // lowercases
['example.com.', 'example.com'], // trims trailing dot
['[2001:db8::1]:8443', '2001:db8::1'], // IPv6 with brackets+port
['2001:db8::1', '2001:db8::1'] // plain IPv6 stays
])('normalizeHost(%o) -> %o', (input, expected) => {
expect(normalizeHost(input)).toBe(expected)
})
it('does not strip non-numeric suffixes (not a port pattern)', () => {
expect(normalizeHost('example.com:abc')).toBe('example.com:abc')
expect(normalizeHost('127.0.0.1:abc')).toBe('127.0.0.1:abc')
})
})
describe('isHostWhitelisted', () => {
describe('localhost label', () => {
it.each([
'localhost',
'LOCALHOST',
'localhost.',
'localhost:5173',
'foo.localhost',
'Foo.Localhost',
'sub.foo.localhost',
'foo.localhost:5173'
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'localhost.com',
'evil-localhost',
'notlocalhost',
'foo.localhost.evil'
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
})
describe('IPv4 127/8 loopback', () => {
it.each([
'127.0.0.1',
'127.1.2.3',
'127.255.255.255',
'127.0.0.1:3000',
'127.000.000.001', // leading zeros are still digits 0-255
'127.0.0.1.' // trailing dot should be tolerated
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'126.0.0.1',
'127.256.0.1',
'127.-1.0.1',
'127.0.0.1:abc',
'128.0.0.1',
'192.168.1.10',
'10.0.0.2',
'0.0.0.0',
'255.255.255.255',
'127.0.0', // malformed
'127.0.0.1.5' // malformed
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
})
describe('IPv6 loopback ::1 (all textual forms)', () => {
it.each([
'::1',
'[::1]',
'[::1]:5173',
'::0001',
'0:0:0:0:0:0:0:1',
'0000:0000:0000:0000:0000:0000:0000:0001',
// Compressed equivalents of ::1 (with zeros compressed)
'0:0::1',
'0:0:0:0:0:0::1',
'::0:1' // compressing the initial zeros (still ::1 when expanded)
])('should allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(true)
})
it.each([
'::2',
'::',
'::0',
'0:0:0:0:0:0:0:2',
'fe80::1', // link-local, not loopback
'2001:db8::1',
'::1:5173', // bracketless "port-like" suffix must not pass
':::1', // invalid (triple colon)
'0:0:0:0:0:0:::1', // invalid compression
'[::1%25lo0]',
'[::1%25lo0]:5173',
'::1%25lo0'
])('should NOT allow %o', (input) => {
expect(isHostWhitelisted(input)).toBe(false)
})
it('should reject empty/whitespace-only input', () => {
expect(isHostWhitelisted('')).toBe(false)
expect(isHostWhitelisted(' ')).toBe(false)
})
})
})
})