Repository: pypi/warehouse

Issue:
Add javascript/frontend validation of breached passwords
In https://github.com/pypa/warehouse/pull/4468 we've started blocking new passwords (via registration, reset, or password change) that have previously been in a data breach of a compromised website. However, that validation currently only occurs server side, it would be nice to also have it occur client side.

This can happen in two ways:

* Have the client JS hit the HIBP API directly, effectively re-implementing the logic that exists in the Python code.
* Have the client JS hit a private API endpoint on Warehouse, that calls into the logic that exists there.
  * If we do this, what should this API look like? Do we want to just send the password over the wire (since that's going to happen on submit anyways?), do we want to prehash it with sha1 and send that over the wire? Or do we want to reimplement the HIBP api in Warehouse? (this would only prevent leaking our users PII via additional http requests to an external service).

Of course, the other question is whether we want to do this or not. It's hard to do it without either reimplementing logic, or sending the password to Warehouse as the user is typing it, neither of which are ideal. Maybe having this validation happen only server side is ok?


Discussion:
I see value in having the registration form perform this check in the frontend alongside having the strength one using zxcvbn. Admittedtly the logic is simpler in that case but this one [doesn't look that difficult to replicate](https://github.com/pypa/warehouse/blob/99e6c7eaa60696098c06506366fc30613e484513/warehouse/accounts/services.py#L339-L391).

Maybe we could call the HIBP API only when the user focuses away from the password field and/or when focusing on the confirmation input instead on every keypress?
We'd be interested in having volunteers do this work and add this feature to Warehouse! @yeraydiazdiaz is this something you're interested in working on?
Hi @brainwane, nice to have you back 🙂 

Yeah, I could throw some cycles at this.
@yeraydiazdiaz great! Have you had any time to poke at this?
@brainwane I started some time ago but never got far with it, I may get some time to work on this soon though.
That would be great if you could, @yeraydiazdiaz!

Resolving patch:
diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py
--- a/warehouse/accounts/services.py
+++ b/warehouse/accounts/services.py
@@ -517,9 +517,7 @@ def __eq__(self, other):
 @implementer(IPasswordBreachedService)
 class HaveIBeenPwnedPasswordBreachedService:
 
-    _failure_message_preamble = (
-        "This password appears in a breach or has been compromised and cannot be used."
-    )
+    _failure_message_preamble = "This password appears in a security breach or has been compromised and cannot be used."
 
     def __init__(
         self,
@@ -603,7 +601,7 @@ def check_password(self, password, *, tags=None):
         #   0136E006E24E7D152139815FB0FC6A50B15:2
         #   ...
         #
-        # THat is, it is a line delimited textual data, where each line is a hash, a
+        # That is, it is a line delimited textual data, where each line is a hash, a
         # colon, and then the number of times that password has appeared in a breach.
         # For our uses, we're going to consider any password that has ever appeared in
         # a breach to be insecure, even if only once.
diff --git a/warehouse/csp.py b/warehouse/csp.py
--- a/warehouse/csp.py
+++ b/warehouse/csp.py
@@ -82,6 +82,7 @@ def includeme(config):
                     "https://api.github.com/repos/",
                     "*.fastly-insights.com",
                     "sentry.io",
+                    "https://api.pwnedpasswords.com",
                 ]
                 + [
                     item


Test patch:
diff --git a/tests/frontend/__mocks__/debounce.js b/tests/frontend/__mocks__/debounce.js
new file mode 100644
--- /dev/null
+++ b/tests/frontend/__mocks__/debounce.js
@@ -0,0 +1,33 @@
+/**
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+
+Mock debounce module. Jest will import this file automatically
+on all test suites that import debounce. For more information see:
+https://jestjs.io/docs/en/manual-mocks
+
+*/
+
+/* global jest, module */
+
+const debounce = jest.genMockFromModule("debounce");
+
+function mockDebounce(fn) {
+  // Return the wrapped function unchanged.
+  return fn;
+}
+
+debounce.debounce = mockDebounce;
+module.exports = debounce;
diff --git a/tests/frontend/password_breach_controller_test.js b/tests/frontend/password_breach_controller_test.js
new file mode 100644
--- /dev/null
+++ b/tests/frontend/password_breach_controller_test.js
@@ -0,0 +1,87 @@
+/* Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* global expect, beforeEach, describe, it */
+
+import { fireEvent } from "@testing-library/dom";
+import { Application } from "stimulus";
+import { delay } from "./utils";
+import PasswordBreachController from "../../warehouse/static/js/warehouse/controllers/password_breach_controller";
+
+let application = null;
+
+describe("Password breach controller", () => {
+  beforeEach(() => {
+    document.body.innerHTML = `
+  <div id="controller" data-controller="password-breach">
+    <input id="password" data-target="password-breach.password" data-action="input->password-breach#check" placeholder="Your password" type="password" />
+    <p id="message" data-target="password-breach.message" class="hidden">Password breached</p>
+  </div>
+    `;
+
+    application = Application.start();
+    application.register("password-breach", PasswordBreachController);
+  });
+
+  describe("initial state", () => {
+    describe("the message", () => {
+      it("is hidden", () => {
+        const message = document.getElementById("message");
+        expect(message).toHaveClass("hidden");
+      });
+    });
+  });
+
+  describe("functionality", () => {
+    beforeEach(() => {
+      fetch.resetMocks();
+    });
+
+    describe("entering a password with less than 3 characters", () => {
+      it("does not call the HIBP API", async () => {
+        const passwordField = document.querySelector("#password");
+        fireEvent.input(passwordField, { target: { value: "fo" } });
+
+        await delay(25);  // arbitrary number of ms, too low may cause failures
+        expect(fetch.mock.calls.length).toEqual(0);
+      });
+    });
+
+    describe("entering a breached password with more than 2 characters", () => {
+      it("calls the HIBP API and shows the message", async () => {
+        // The response must match the slice of the hashed password
+        fetch.mockResponse("7B5EA3F0FDBC95D0DD47F3C5BC275DA8A33:5270");
+        const passwordField = document.querySelector("#password");
+        fireEvent.input(passwordField, { target: { value: "foo" } });
+
+        await delay(25);
+        expect(fetch.mock.calls.length).toEqual(1);
+        expect(document.getElementById("message")).not.toHaveClass("hidden");
+      });
+    });
+
+    describe("entering a safe password with more than 2 characters", () => {
+      it("calls the HIBP API and does not show the message", async () => {
+        const verySecurePassword = "^woHw6w4j8zShVPyWtNKFn&DspydLQtIFPk97T@k$78H3pRsJ9RNB5SpLIux";
+        // the response does not match the sliche of the hashed password
+        fetch.mockResponse("00DC70F3D981248DF52C620198328108406:4");
+        const passwordField = document.querySelector("#password");
+        fireEvent.input(passwordField, { target: { value: verySecurePassword } });
+
+        await delay(25);
+        expect(fetch.mock.calls.length).toEqual(1);
+        expect(document.getElementById("message")).toHaveClass("hidden");
+      });
+    });
+  });
+});
diff --git a/tests/frontend/setup.js b/tests/frontend/setup.js
--- a/tests/frontend/setup.js
+++ b/tests/frontend/setup.js
@@ -11,6 +11,8 @@
  * limitations under the License.
  */
 
+/* global fetch */
+
 // Setup MutationObserver shim since jsdom doesn't
 // support it out of the box.
 
@@ -36,3 +38,12 @@ import "@testing-library/jest-dom/extend-expect";
 
 // Required to use async/await in tests
 import "@babel/polyfill";
+
+// Monkeypatch the global fetch API
+fetch = require("jest-fetch-mock");  // eslint-disable-line no-global-assign
+
+// Make TextEncoder and cryto available in the global scope
+// in the same way as in a browser environment
+window.TextEncoder = require("util").TextEncoder;
+const WebCrypto = require("node-webcrypto-ossl");
+window.crypto = new WebCrypto();
diff --git a/tests/frontend/utils.js b/tests/frontend/utils.js
new file mode 100644
--- /dev/null
+++ b/tests/frontend/utils.js
@@ -0,0 +1,27 @@
+/* Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+export function domReady() {
+  return new Promise(resolve => {
+    if (document.readyState == "loading") {
+      document.addEventListener("DOMContentLoaded", resolve);
+    } else {
+      resolve();
+    }
+  });
+}
+
+export function delay(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms || 0));
+}
diff --git a/tests/unit/accounts/test_services.py b/tests/unit/accounts/test_services.py
--- a/tests/unit/accounts/test_services.py
+++ b/tests/unit/accounts/test_services.py
@@ -904,15 +904,15 @@ def test_factory(self):
             (
                 None,
                 (
-                    "This password appears in a breach or has been compromised and "
-                    "cannot be used."
+                    "This password appears in a security breach or has "
+                    "been compromised and cannot be used."
                 ),
             ),
             (
                 "http://localhost/help/#compromised-password",
                 (
-                    "This password appears in a breach or has been compromised and "
-                    "cannot be used. See "
+                    "This password appears in a security breach or has been "
+                    "compromised and cannot be used. See "
                     '<a href="http://localhost/help/#compromised-password">'
                     "this FAQ entry</a> for more information."
                 ),
@@ -939,15 +939,15 @@ def test_failure_message(self, help_url, expected):
             (
                 None,
                 (
-                    "This password appears in a breach or has been compromised and "
-                    "cannot be used."
+                    "This password appears in a security breach or has been "
+                    "compromised and cannot be used."
                 ),
             ),
             (
                 "http://localhost/help/#compromised-password",
                 (
-                    "This password appears in a breach or has been compromised and "
-                    "cannot be used. See the FAQ entry at "
+                    "This password appears in a security breach or has been "
+                    "compromised and cannot be used. See the FAQ entry at "
                     "http://localhost/help/#compromised-password for more information."
                 ),
             ),
diff --git a/tests/unit/test_csp.py b/tests/unit/test_csp.py
--- a/tests/unit/test_csp.py
+++ b/tests/unit/test_csp.py
@@ -203,6 +203,7 @@ def test_includeme():
                         "https://api.github.com/repos/",
                         "*.fastly-insights.com",
                         "sentry.io",
+                        "https://api.pwnedpasswords.com",
                         "https://2p66nmmycsj3.statuspage.io",
                     ],
                     "default-src": ["'none'"],