From 93e457da7b42ff55c366f6ccd8d14dc6a444e3ba Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 05:41:59 +0000 Subject: [PATCH 1/9] user/edit/password: check haveibeenpwned.com --- .../src/components/user/edit/password.vue | 46 ++++++++++++------ apps/central/src/util/password.js | 47 +++++++++++++++++++ 2 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 apps/central/src/util/password.js diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 7da343aa6..5a30d9529 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -24,7 +24,11 @@ except according to the terms contained in the LICENSE file. autocomplete="current-password"/> + :has-error="tooShort || mismatch || !!strError" autocomplete="new-password"> + + @@ -46,6 +50,7 @@ import useRequest from '../../../composables/request'; import { apiPaths } from '../../../util/request'; import { noop } from '../../../util/util'; import { useRequestData } from '../../../request-data'; +import { checkPasswordPwnage } from '../../../util/password'; export default { name: 'UserEditPassword', @@ -62,13 +67,15 @@ export default { newPassword: '', tooShort: false, confirm: '', - mismatch: false + mismatch: false, + strError: '', }; }, methods: { validate() { this.tooShort = false; this.mismatch = false; + this.strError = ''; if (this.newPassword.length < 10) { this.alert.danger(this.$t('alert.passwordTooShort')); @@ -86,19 +93,30 @@ export default { }, submit() { if (!this.validate()) return; - const data = { old: this.oldPassword, new: this.newPassword }; - this.request({ - method: 'PUT', - url: apiPaths.password(this.user.id), - data - }) - .then(() => { - this.alert.success(this.$t('alert.success')); - // The Chrome password manager does not realize that the form was - // submitted. Should we navigate to a different page so that it does? - }) - .catch(noop); + (async () => { + const isPwned = await checkPasswordPwnage(this.newPassword); + if (isPwned) { + this.strError = ` +

This password has previously been included in a breach.

+

For more information, see here.

+ `; + } else { + const data = { old: this.oldPassword, new: this.newPassword }; + this.request({ + method: 'PUT', + url: apiPaths.password(this.user.id), + data + }) + .then(() => { + this.alert.success(this.$t('alert.success')); + + // The Chrome password manager does not realize that the form was + // submitted. Should we navigate to a different page so that it does? + }) + .catch(noop); + } + })(); } } }; diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js new file mode 100644 index 000000000..fbc5af19c --- /dev/null +++ b/apps/central/src/util/password.js @@ -0,0 +1,47 @@ +const maxCacheLength = 10; +const hashCache = []; + +async function getSuffixesFor(prefix) { + const cachedHashes = hashCache.find(cached => cached.prefix === prefix); + if(cachedHashes) return cachedHashes.suffixes; + + try { + const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); + if(!res.ok) throw new Error(`Bad response: ${res.status}`); + + const body = await res.text(); + const suffixes = body.split('\n').map(line => line.split(':')[0]); + + if(hashCache.length === maxCacheLength) hashCache.shift(); + + hashCache.push({ prefix, suffixes }); + + return suffixes; + } catch(err) { + console.log('pwned check failed:', err); // eslint-disable-line no-console + // if we can't check, just let them use it + return []; + } +} + +export async function checkPasswordPwnage(password) { + const hash = await digestMessage(password); + + const hashPrefix = hash.substring(0, 5); + const hashSuffix = hash.substring(5); + + const suffixes = await getSuffixesFor(hashPrefix); + + return suffixes.includes(hashSuffix); +} + +// from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string +async function digestMessage(message) { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray + .map(b => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} From 96e14a5cc199ed057324eb3391f0418575827565 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:02:22 +0000 Subject: [PATCH 2/9] lint --- apps/central/src/components/user/edit/password.vue | 2 +- apps/central/src/util/password.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 5a30d9529..7c0aaf62f 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -102,7 +102,7 @@ export default {

For more information, see here.

`; } else { - const data = { old: this.oldPassword, new: this.newPassword }; + const data = { old: this.oldPassword, new: this.newPassword }; this.request({ method: 'PUT', url: apiPaths.password(this.user.id), diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index fbc5af19c..08d8358b6 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -3,16 +3,16 @@ const hashCache = []; async function getSuffixesFor(prefix) { const cachedHashes = hashCache.find(cached => cached.prefix === prefix); - if(cachedHashes) return cachedHashes.suffixes; + if (cachedHashes) return cachedHashes.suffixes; try { const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); - if(!res.ok) throw new Error(`Bad response: ${res.status}`); + if (!res.ok) throw new Error(`Bad response: ${res.status}`); const body = await res.text(); const suffixes = body.split('\n').map(line => line.split(':')[0]); - if(hashCache.length === maxCacheLength) hashCache.shift(); + if (hashCache.length === maxCacheLength) hashCache.shift(); hashCache.push({ prefix, suffixes }); From 866ce502ffcac020b450bad366de02c9a621b9ed Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:03:25 +0000 Subject: [PATCH 3/9] lint --- apps/central/src/util/password.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index 08d8358b6..5cb4f6476 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -17,7 +17,7 @@ async function getSuffixesFor(prefix) { hashCache.push({ prefix, suffixes }); return suffixes; - } catch(err) { + } catch (err) { console.log('pwned check failed:', err); // eslint-disable-line no-console // if we can't check, just let them use it return []; @@ -25,14 +25,14 @@ async function getSuffixesFor(prefix) { } export async function checkPasswordPwnage(password) { - const hash = await digestMessage(password); + const hash = await digestMessage(password); // eslint-disable-line no-use-before-define const hashPrefix = hash.substring(0, 5); const hashSuffix = hash.substring(5); const suffixes = await getSuffixesFor(hashPrefix); - return suffixes.includes(hashSuffix); + return suffixes.includes(hashSuffix); } // from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string From c8fc03f8eb636eba83bf23f5fd7fc234b34ca26f Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:03:46 +0000 Subject: [PATCH 4/9] lint --- apps/central/src/components/user/edit/password.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 7c0aaf62f..e6bdc5381 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -25,7 +25,7 @@ except according to the terms contained in the LICENSE file. - diff --git a/apps/central/src/components/password-strength.vue b/apps/central/src/components/password-strength.vue index d6c3dfd7d..1b45e15c5 100644 --- a/apps/central/src/components/password-strength.vue +++ b/apps/central/src/components/password-strength.vue @@ -15,7 +15,9 @@ vue-password-strength-meter 1.7.2, which uses the MIT license. https://github.com/apertureless/vue-password-strength-meter --> @@ -46,12 +48,16 @@ const score = computed(() => { @import '../assets/scss/mixins'; .password-strength { + position: relative; + height: 2px; +} + +.inner { background-color: #ddd; - float: right; height: 2px; - margin-bottom: 20px; - margin-top: 10px; - position: relative; + position: absolute; + right: 0; + top: 10px; width: 50%; // Use the borders of two pseduo-elements to create 4 blank spaces (gaps), diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index dc640750d..fbda317f3 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -124,7 +124,7 @@ export default { From 226180d268cdef664bb775056df56fba748501ee Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:55:53 +0000 Subject: [PATCH 8/9] e2e test? --- vite.config.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vite.config.js b/vite.config.js index 0e68550fd..17fc9f762 100644 --- a/vite.config.js +++ b/vite.config.js @@ -118,8 +118,9 @@ export default defineConfig(({ mode }) => ({ }, // Not sure why this is needed in addition to build.target above and why it's // only an issue in development. `npm run dev` doesn't work without this. - optimizeDeps: mode === 'development' - ? { esbuildOptions: { target: buildTarget } } - : {}, + optimizeDeps: { + include: ['zxcvbn'], + ...(mode === 'development' ? { esbuildOptions: { target: buildTarget } } : {}), + }, server: devServer })); From 46fe608c53cb8b0c10bc8f8c1c29bb661f75f227 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:56:09 +0000 Subject: [PATCH 9/9] revert change --- vite.config.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/vite.config.js b/vite.config.js index 17fc9f762..0e68550fd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -118,9 +118,8 @@ export default defineConfig(({ mode }) => ({ }, // Not sure why this is needed in addition to build.target above and why it's // only an issue in development. `npm run dev` doesn't work without this. - optimizeDeps: { - include: ['zxcvbn'], - ...(mode === 'development' ? { esbuildOptions: { target: buildTarget } } : {}), - }, + optimizeDeps: mode === 'development' + ? { esbuildOptions: { target: buildTarget } } + : {}, server: devServer }));