From 9c97fb359daf14df7704f3b15cb889877a8d382c Mon Sep 17 00:00:00 2001
From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Date: Thu, 2 Oct 2025 09:09:11 +0300
Subject: [PATCH] feat(auth): Allow SSO login only for whitelisted addresses
(localhost) (#5815)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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:
UI opened from default `127.0.0.1` address(nothing changed):
┆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)
---
.../dialog/content/SignInContent.vue | 62 ++++-----
src/utils/hostWhitelist.ts | 91 +++++++++++++
tests-ui/tests/utils/hostWhitelist.test.ts | 123 ++++++++++++++++++
3 files changed, 247 insertions(+), 29 deletions(-)
create mode 100644 src/utils/hostWhitelist.ts
create mode 100644 tests-ui/tests/utils/hostWhitelist.test.ts
diff --git a/src/components/dialog/content/SignInContent.vue b/src/components/dialog/content/SignInContent.vue
index 3f3a47b7b..29f441a28 100644
--- a/src/components/dialog/content/SignInContent.vue
+++ b/src/components/dialog/content/SignInContent.vue
@@ -45,37 +45,39 @@
{{ t('auth.login.orContinueWith') }}
-
+
-
-
- {{
- isSignIn
- ? t('auth.login.loginWithGoogle')
- : t('auth.signup.signUpWithGoogle')
- }}
-
+
+
+
+ {{
+ isSignIn
+ ? t('auth.login.loginWithGoogle')
+ : t('auth.signup.signUpWithGoogle')
+ }}
+
-
-
- {{
- isSignIn
- ? t('auth.login.loginWithGithub')
- : t('auth.signup.signUpWithGithub')
- }}
-
+
+
+ {{
+ isSignIn
+ ? t('auth.login.loginWithGithub')
+ : t('auth.signup.signUpWithGithub')
+ }}
+
+
{
isSignIn.value = !isSignIn.value
diff --git a/src/utils/hostWhitelist.ts b/src/utils/hostWhitelist.ts
new file mode 100644
index 000000000..3fb1ae2c3
--- /dev/null
+++ b/src/utils/hostWhitelist.ts
@@ -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 0–255 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
+}
diff --git a/tests-ui/tests/utils/hostWhitelist.test.ts b/tests-ui/tests/utils/hostWhitelist.test.ts
new file mode 100644
index 000000000..cd3506dff
--- /dev/null
+++ b/tests-ui/tests/utils/hostWhitelist.test.ts
@@ -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)
+ })
+ })
+ })
+})