diff options
-rw-r--r-- | docs/transparent/osx.rst | 3 | ||||
-rw-r--r-- | examples/complex/change_upstream_proxy.py | 7 | ||||
-rw-r--r-- | examples/complex/har_dump.py | 6 | ||||
-rw-r--r-- | examples/complex/sslstrip.py | 11 | ||||
-rwxr-xr-x | examples/complex/xss_scanner.py | 87 | ||||
-rw-r--r-- | mitmproxy/addons/cut.py | 5 | ||||
-rw-r--r-- | mitmproxy/addons/export.py | 5 | ||||
-rw-r--r-- | mitmproxy/tools/console/overlay.py | 31 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | test/examples/test_xss_scanner.py | 8 | ||||
-rw-r--r-- | test/mitmproxy/addons/test_cut.py | 8 | ||||
-rw-r--r-- | test/mitmproxy/addons/test_export.py | 13 | ||||
-rw-r--r-- | test/mitmproxy/tools/console/test_keymap.py | 2 | ||||
-rw-r--r-- | tox.ini | 5 | ||||
-rw-r--r-- | web/package.json | 3 | ||||
-rw-r--r-- | web/src/js/components/FlowTable/FlowColumns.jsx | 2 | ||||
-rw-r--r-- | web/src/js/ducks/utils/store.js | 6 | ||||
-rw-r--r-- | web/yarn.lock | 4 |
18 files changed, 134 insertions, 74 deletions
diff --git a/docs/transparent/osx.rst b/docs/transparent/osx.rst index 40e91fac..5d4ec612 100644 --- a/docs/transparent/osx.rst +++ b/docs/transparent/osx.rst @@ -17,8 +17,7 @@ Note that this means we don't support transparent mode for earlier versions of O .. code-block:: none - rdr on en2 inet proto tcp to any port 80 -> 127.0.0.1 port 8080 - rdr on en2 inet proto tcp to any port 443 -> 127.0.0.1 port 8080 + rdr on en0 inet proto tcp to any port {80, 443} -> 127.0.0.1 port 8080 These rules tell pf to redirect all traffic destined for port 80 or 443 to the local mitmproxy instance running on port 8080. You should diff --git a/examples/complex/change_upstream_proxy.py b/examples/complex/change_upstream_proxy.py index 49d5379f..089a9df5 100644 --- a/examples/complex/change_upstream_proxy.py +++ b/examples/complex/change_upstream_proxy.py @@ -1,3 +1,6 @@ +from mitmproxy import http +import typing + # This scripts demonstrates how mitmproxy can switch to a second/different upstream proxy # in upstream proxy mode. # @@ -6,7 +9,7 @@ # If you want to change the target server, you should modify flow.request.host and flow.request.port -def proxy_address(flow): +def proxy_address(flow: http.HTTPFlow) -> typing.Tuple[str, int]: # Poor man's loadbalancing: route every second domain through the alternative proxy. if hash(flow.request.host) % 2 == 1: return ("localhost", 8082) @@ -14,7 +17,7 @@ def proxy_address(flow): return ("localhost", 8081) -def request(flow): +def request(flow: http.HTTPFlow) -> None: if flow.request.method == "CONNECT": # If the decision is done by domain, one could also modify the server address here. # We do it after CONNECT here to have the request data available as well. diff --git a/examples/complex/har_dump.py b/examples/complex/har_dump.py index 66a81a7d..9e287a19 100644 --- a/examples/complex/har_dump.py +++ b/examples/complex/har_dump.py @@ -7,22 +7,24 @@ import json import base64 import zlib import os +import typing # noqa from datetime import datetime from datetime import timezone import mitmproxy +from mitmproxy import connections # noqa from mitmproxy import version from mitmproxy import ctx from mitmproxy.utils import strutils from mitmproxy.net.http import cookies -HAR = {} +HAR = {} # type: typing.Dict # A list of server seen till now is maintained so we can avoid # using 'connect' time for entries that use an existing connection. -SERVERS_SEEN = set() +SERVERS_SEEN = set() # type: typing.Set[connections.ServerConnection] def load(l): diff --git a/examples/complex/sslstrip.py b/examples/complex/sslstrip.py index 2f60c8b9..c3f8c4f7 100644 --- a/examples/complex/sslstrip.py +++ b/examples/complex/sslstrip.py @@ -3,13 +3,16 @@ This script implements an sslstrip-like attack based on mitmproxy. https://moxie.org/software/sslstrip/ """ import re -import urllib +import urllib.parse +import typing # noqa + +from mitmproxy import http # set of SSL/TLS capable hosts -secure_hosts = set() +secure_hosts = set() # type: typing.Set[str] -def request(flow): +def request(flow: http.HTTPFlow) -> None: flow.request.headers.pop('If-Modified-Since', None) flow.request.headers.pop('Cache-Control', None) @@ -27,7 +30,7 @@ def request(flow): flow.request.host = flow.request.pretty_host -def response(flow): +def response(flow: http.HTTPFlow) -> None: flow.response.headers.pop('Strict-Transport-Security', None) flow.response.headers.pop('Public-Key-Pins', None) diff --git a/examples/complex/xss_scanner.py b/examples/complex/xss_scanner.py index 4b35c6c1..0ee38cd4 100755 --- a/examples/complex/xss_scanner.py +++ b/examples/complex/xss_scanner.py @@ -35,14 +35,17 @@ Line: 1029zxcs'd"ao<ac>so[sb]po(pc)se;sl/bsl\eq=3847asd """ -from mitmproxy import ctx +from html.parser import HTMLParser +from typing import Dict, Union, Tuple, Optional, List, NamedTuple from socket import gaierror, gethostbyname from urllib.parse import urlparse -import requests import re -from html.parser import HTMLParser + +import requests + from mitmproxy import http -from typing import Dict, Union, Tuple, Optional, List, NamedTuple +from mitmproxy import ctx + # The actual payload is put between a frontWall and a backWall to make it easy # to locate the payload with regular expressions @@ -83,15 +86,16 @@ def get_cookies(flow: http.HTTPFlow) -> Cookies: return {name: value for name, value in flow.request.cookies.fields} -def find_unclaimed_URLs(body: Union[str, bytes], requestUrl: bytes) -> None: +def find_unclaimed_URLs(body: str, requestUrl: bytes) -> None: """ Look for unclaimed URLs in script tags and log them if found""" - def getValue(attrs: List[Tuple[str, str]], attrName: str) -> str: + def getValue(attrs: List[Tuple[str, str]], attrName: str) -> Optional[str]: for name, value in attrs: if attrName == name: return value + return None class ScriptURLExtractor(HTMLParser): - script_URLs = [] + script_URLs = [] # type: List[str] def handle_starttag(self, tag, attrs): if (tag == "script" or tag == "iframe") and "src" in [name for name, value in attrs]: @@ -100,13 +104,10 @@ def find_unclaimed_URLs(body: Union[str, bytes], requestUrl: bytes) -> None: self.script_URLs.append(getValue(attrs, "href")) parser = ScriptURLExtractor() - try: - parser.feed(body) - except TypeError: - parser.feed(body.decode('utf-8')) + parser.feed(body) for url in parser.script_URLs: - parser = urlparse(url) - domain = parser.netloc + url_parser = urlparse(url) + domain = url_parser.netloc try: gethostbyname(domain) except gaierror: @@ -178,10 +179,11 @@ def log_SQLi_data(sqli_info: Optional[SQLiData]) -> None: if not sqli_info: return ctx.log.error("===== SQLi Found =====") - ctx.log.error("SQLi URL: %s" % sqli_info.url.decode('utf-8')) - ctx.log.error("Injection Point: %s" % sqli_info.injection_point.decode('utf-8')) - ctx.log.error("Regex used: %s" % sqli_info.regex.decode('utf-8')) - ctx.log.error("Suspected DBMS: %s" % sqli_info.dbms.decode('utf-8')) + ctx.log.error("SQLi URL: %s" % sqli_info.url) + ctx.log.error("Injection Point: %s" % sqli_info.injection_point) + ctx.log.error("Regex used: %s" % sqli_info.regex) + ctx.log.error("Suspected DBMS: %s" % sqli_info.dbms) + return def get_SQLi_data(new_body: str, original_body: str, request_URL: str, injection_point: str) -> Optional[SQLiData]: @@ -202,20 +204,21 @@ def get_SQLi_data(new_body: str, original_body: str, request_URL: str, injection "Sybase": (r"(?i)Warning.*sybase.*", r"Sybase message", r"Sybase.*Server message.*"), } for dbms, regexes in DBMS_ERRORS.items(): - for regex in regexes: + for regex in regexes: # type: ignore if re.search(regex, new_body, re.IGNORECASE) and not re.search(regex, original_body, re.IGNORECASE): return SQLiData(request_URL, injection_point, regex, dbms) + return None # A qc is either ' or " -def inside_quote(qc: str, substring: bytes, text_index: int, body: bytes) -> bool: +def inside_quote(qc: str, substring_bytes: bytes, text_index: int, body_bytes: bytes) -> bool: """ Whether the Numberth occurence of the first string in the second string is inside quotes as defined by the supplied QuoteChar """ - substring = substring.decode('utf-8') - body = body.decode('utf-8') + substring = substring_bytes.decode('utf-8') + body = body_bytes.decode('utf-8') num_substrings_found = 0 in_quote = False for index, char in enumerate(body): @@ -238,20 +241,20 @@ def inside_quote(qc: str, substring: bytes, text_index: int, body: bytes) -> boo return False -def paths_to_text(html: str, str: str) -> List[str]: +def paths_to_text(html: str, string: str) -> List[str]: """ Return list of Paths to a given str in the given HTML tree - Note that it does a BFS """ - def remove_last_occurence_of_sub_string(str: str, substr: str): + def remove_last_occurence_of_sub_string(string: str, substr: str) -> str: """ Delete the last occurence of substr from str String String -> String """ - index = str.rfind(substr) - return str[:index] + str[index + len(substr):] + index = string.rfind(substr) + return string[:index] + string[index + len(substr):] class PathHTMLParser(HTMLParser): currentPath = "" - paths = [] + paths = [] # type: List[str] def handle_starttag(self, tag, attrs): self.currentPath += ("/" + tag) @@ -260,7 +263,7 @@ def paths_to_text(html: str, str: str) -> List[str]: self.currentPath = remove_last_occurence_of_sub_string(self.currentPath, "/" + tag) def handle_data(self, data): - if str in data: + if string in data: self.paths.append(self.currentPath) parser = PathHTMLParser() @@ -268,7 +271,7 @@ def paths_to_text(html: str, str: str) -> List[str]: return parser.paths -def get_XSS_data(body: str, request_URL: str, injection_point: str) -> Optional[XSSData]: +def get_XSS_data(body: Union[str, bytes], request_URL: str, injection_point: str) -> Optional[XSSData]: """ Return a XSSDict if there is a XSS otherwise return None """ def in_script(text, index, body) -> bool: """ Whether the Numberth occurence of the first string in the second @@ -314,9 +317,9 @@ def get_XSS_data(body: str, request_URL: str, injection_point: str) -> Optional[ matches = regex.findall(body) for index, match in enumerate(matches): # Where the string is injected into the HTML - in_script = in_script(match, index, body) - in_HTML = in_HTML(match, index, body) - in_tag = not in_script and not in_HTML + in_script_val = in_script(match, index, body) + in_HTML_val = in_HTML(match, index, body) + in_tag = not in_script_val and not in_HTML_val in_single_quotes = inside_quote("'", match, index, body) in_double_quotes = inside_quote('"', match, index, body) # Whether you can inject: @@ -327,17 +330,17 @@ def get_XSS_data(body: str, request_URL: str, injection_point: str) -> Optional[ inject_slash = b"sl/bsl" in match # forward slashes inject_semi = b"se;sl" in match # semicolons inject_equals = b"eq=" in match # equals sign - if in_script and inject_slash and inject_open_angle and inject_close_angle: # e.g. <script>PAYLOAD</script> + if in_script_val and inject_slash and inject_open_angle and inject_close_angle: # e.g. <script>PAYLOAD</script> return XSSData(request_URL, injection_point, '</script><script>alert(0)</script><script>', match.decode('utf-8')) - elif in_script and in_single_quotes and inject_single_quotes and inject_semi: # e.g. <script>t='PAYLOAD';</script> + elif in_script_val and in_single_quotes and inject_single_quotes and inject_semi: # e.g. <script>t='PAYLOAD';</script> return XSSData(request_URL, injection_point, "';alert(0);g='", match.decode('utf-8')) - elif in_script and in_double_quotes and inject_double_quotes and inject_semi: # e.g. <script>t="PAYLOAD";</script> + elif in_script_val and in_double_quotes and inject_double_quotes and inject_semi: # e.g. <script>t="PAYLOAD";</script> return XSSData(request_URL, injection_point, '";alert(0);g="', @@ -380,33 +383,35 @@ def get_XSS_data(body: str, request_URL: str, injection_point: str) -> Optional[ injection_point, " onmouseover=alert(0) t=", match.decode('utf-8')) - elif in_HTML and not in_script and inject_open_angle and inject_close_angle and inject_slash: # e.g. <html>PAYLOAD</html> + elif in_HTML_val and not in_script_val and inject_open_angle and inject_close_angle and inject_slash: # e.g. <html>PAYLOAD</html> return XSSData(request_URL, injection_point, '<script>alert(0)</script>', match.decode('utf-8')) else: return None + return None # response is mitmproxy's entry point def response(flow: http.HTTPFlow) -> None: - cookiesDict = get_cookies(flow) + cookies_dict = get_cookies(flow) + resp = flow.response.get_text(strict=False) # Example: http://xss.guru/unclaimedScriptTag.html - find_unclaimed_URLs(flow.response.content, flow.request.url) - results = test_end_of_URL_injection(flow.response.content.decode('utf-8'), flow.request.url, cookiesDict) + find_unclaimed_URLs(resp, flow.request.url) + results = test_end_of_URL_injection(resp, flow.request.url, cookies_dict) log_XSS_data(results[0]) log_SQLi_data(results[1]) # Example: https://daviddworken.com/vulnerableReferer.php - results = test_referer_injection(flow.response.content.decode('utf-8'), flow.request.url, cookiesDict) + results = test_referer_injection(resp, flow.request.url, cookies_dict) log_XSS_data(results[0]) log_SQLi_data(results[1]) # Example: https://daviddworken.com/vulnerableUA.php - results = test_user_agent_injection(flow.response.content.decode('utf-8'), flow.request.url, cookiesDict) + results = test_user_agent_injection(resp, flow.request.url, cookies_dict) log_XSS_data(results[0]) log_SQLi_data(results[1]) if "?" in flow.request.url: # Example: https://daviddworken.com/vulnerable.php?name= - results = test_query_injection(flow.response.content.decode('utf-8'), flow.request.url, cookiesDict) + results = test_query_injection(resp, flow.request.url, cookies_dict) log_XSS_data(results[0]) log_SQLi_data(results[1]) diff --git a/mitmproxy/addons/cut.py b/mitmproxy/addons/cut.py index c31465c7..1c8fbc05 100644 --- a/mitmproxy/addons/cut.py +++ b/mitmproxy/addons/cut.py @@ -139,4 +139,7 @@ class Cut: [strutils.always_str(v) or "" for v in vals] # type: ignore ) ctx.log.alert("Clipped %s cuts as CSV." % len(cuts)) - pyperclip.copy(fp.getvalue()) + try: + pyperclip.copy(fp.getvalue()) + except pyperclip.PyperclipException as e: + ctx.log.error(str(e)) diff --git a/mitmproxy/addons/export.py b/mitmproxy/addons/export.py index 173296e4..4bb44548 100644 --- a/mitmproxy/addons/export.py +++ b/mitmproxy/addons/export.py @@ -77,4 +77,7 @@ class Export(): raise exceptions.CommandError("No such export format: %s" % fmt) func = formats[fmt] # type: typing.Any v = strutils.always_str(func(f)) - pyperclip.copy(v) + try: + pyperclip.copy(v) + except pyperclip.PyperclipException as e: + ctx.log.error(str(e)) diff --git a/mitmproxy/tools/console/overlay.py b/mitmproxy/tools/console/overlay.py index 55acbfdd..d255bc8c 100644 --- a/mitmproxy/tools/console/overlay.py +++ b/mitmproxy/tools/console/overlay.py @@ -40,12 +40,17 @@ class SimpleOverlay(urwid.Overlay, layoutwidget.LayoutWidget): class Choice(urwid.WidgetWrap): - def __init__(self, txt, focus, current): + def __init__(self, txt, focus, current, shortcut): + if shortcut: + selection_type = "option_selected_key" if focus else "key" + txt = [(selection_type, shortcut), ") ", txt] + else: + txt = " " + txt if current: s = "option_active_selected" if focus else "option_active" else: s = "option_selected" if focus else "text" - return super().__init__( + super().__init__( urwid.AttrWrap( urwid.Padding(urwid.Text(txt)), s, @@ -60,6 +65,8 @@ class Choice(urwid.WidgetWrap): class ChooserListWalker(urwid.ListWalker): + shortcuts = "123456789abcdefghijklmnoprstuvwxyz" + def __init__(self, choices, current): self.index = 0 self.choices = choices @@ -67,7 +74,7 @@ class ChooserListWalker(urwid.ListWalker): def _get(self, idx, focus): c = self.choices[idx] - return Choice(c, focus, c == self.current) + return Choice(c, focus, c == self.current, self.shortcuts[idx:idx + 1]) def set_focus(self, index): self.index = index @@ -87,6 +94,12 @@ class ChooserListWalker(urwid.ListWalker): return None, None return self._get(pos, False), pos + def choice_by_shortcut(self, shortcut): + for i, choice in enumerate(self.choices): + if shortcut == self.shortcuts[i:i + 1]: + return choice + return None + class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget): keyctx = "chooser" @@ -96,7 +109,8 @@ class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget): self.choices = choices self.callback = callback choicewidth = max([len(i) for i in choices]) - self.width = max(choicewidth, len(title)) + 5 + self.width = max(choicewidth, len(title)) + 7 + self.walker = ChooserListWalker(choices, current) super().__init__( urwid.AttrWrap( @@ -105,7 +119,7 @@ class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget): urwid.ListBox(self.walker), len(choices) ), - title= title + title=title ), "background" ) @@ -116,11 +130,16 @@ class Chooser(urwid.WidgetWrap, layoutwidget.LayoutWidget): def keypress(self, size, key): key = self.master.keymap.handle_only("chooser", key) + choice = self.walker.choice_by_shortcut(key) + if choice: + self.callback(choice) + signals.pop_view_state.send(self) + return if key == "m_select": self.callback(self.choices[self.walker.index]) signals.pop_view_state.send(self) return - elif key == "esc": + elif key in ["q", "esc"]: signals.pop_view_state.send(self) return @@ -69,7 +69,7 @@ setup( 'h11>=0.7.0,<0.8', "h2>=3.0.1,<4", "hyperframe>=5.1.0,<6", - "kaitaistruct>=0.7, <0.8", + "kaitaistruct>=0.7,<0.9", "ldap3>=2.4,<2.5", "passlib>=1.6.5, <1.8", "pyasn1>=0.3.1,<0.5", diff --git a/test/examples/test_xss_scanner.py b/test/examples/test_xss_scanner.py index e15d7e10..8cf06a2a 100644 --- a/test/examples/test_xss_scanner.py +++ b/test/examples/test_xss_scanner.py @@ -343,10 +343,10 @@ class TestXSSScanner(): monkeypatch.setattr("mitmproxy.ctx.log", logger) xss.log_SQLi_data(None) assert logger.args == [] - xss.log_SQLi_data(xss.SQLiData(b'https://example.com', - b'Location', - b'Oracle.*Driver', - b'Oracle')) + xss.log_SQLi_data(xss.SQLiData('https://example.com', + 'Location', + 'Oracle.*Driver', + 'Oracle')) assert logger.args[0] == '===== SQLi Found =====' assert logger.args[1] == 'SQLi URL: https://example.com' assert logger.args[2] == 'Injection Point: Location' diff --git a/test/mitmproxy/addons/test_cut.py b/test/mitmproxy/addons/test_cut.py index 266f9de7..56568f21 100644 --- a/test/mitmproxy/addons/test_cut.py +++ b/test/mitmproxy/addons/test_cut.py @@ -7,6 +7,7 @@ from mitmproxy.test import taddons from mitmproxy.test import tflow from mitmproxy.test import tutils import pytest +import pyperclip from unittest import mock @@ -89,6 +90,13 @@ def test_cut_clip(): tctx.command(c.clip, "@all", "request.method,request.content") assert pc.called + with mock.patch('pyperclip.copy') as pc: + log_message = "Pyperclip could not find a " \ + "copy/paste mechanism for your system." + pc.side_effect = pyperclip.PyperclipException(log_message) + tctx.command(c.clip, "@all", "request.method") + assert tctx.master.has_log(log_message, level="error") + def test_cut_save(tmpdir): f = str(tmpdir.join("path")) diff --git a/test/mitmproxy/addons/test_export.py b/test/mitmproxy/addons/test_export.py index 4ceb0429..07227a7a 100644 --- a/test/mitmproxy/addons/test_export.py +++ b/test/mitmproxy/addons/test_export.py @@ -1,6 +1,8 @@ -import pytest import os +import pytest +import pyperclip + from mitmproxy import exceptions from mitmproxy.addons import export # heh from mitmproxy.test import tflow @@ -111,7 +113,7 @@ def test_export_open(exception, log_message, tmpdir): def test_clip(tmpdir): e = export.Export() - with taddons.context(): + with taddons.context() as tctx: with pytest.raises(exceptions.CommandError): e.clip("nonexistent", tflow.tflow(resp=True)) @@ -122,3 +124,10 @@ def test_clip(tmpdir): with mock.patch('pyperclip.copy') as pc: e.clip("curl", tflow.tflow(resp=True)) assert pc.called + + with mock.patch('pyperclip.copy') as pc: + log_message = "Pyperclip could not find a " \ + "copy/paste mechanism for your system." + pc.side_effect = pyperclip.PyperclipException(log_message) + e.clip("raw", tflow.tflow(resp=True)) + assert tctx.master.has_log(log_message, level="error") diff --git a/test/mitmproxy/tools/console/test_keymap.py b/test/mitmproxy/tools/console/test_keymap.py index 00e64991..7b475ff8 100644 --- a/test/mitmproxy/tools/console/test_keymap.py +++ b/test/mitmproxy/tools/console/test_keymap.py @@ -42,7 +42,7 @@ def test_join(): km = keymap.Keymap(tctx.master) km.add("key", "str", ["options"], "help1") km.add("key", "str", ["commands"]) - return + assert len(km.bindings) == 1 assert len(km.bindings[0].contexts) == 2 assert km.bindings[0].help == "help1" @@ -27,9 +27,8 @@ commands = flake8 --jobs 8 mitmproxy pathod examples test release python test/filename_matching.py rstcheck README.rst - mypy --ignore-missing-imports ./mitmproxy - mypy --ignore-missing-imports ./pathod - mypy --ignore-missing-imports --follow-imports=skip ./examples/simple/ + mypy --ignore-missing-imports ./mitmproxy ./pathod + mypy --ignore-missing-imports --follow-imports=skip ./examples/simple/ ./examples/pathod/ ./examples/complex/ [testenv:individual_coverage] deps = diff --git a/web/package.json b/web/package.json index 31c2d6d6..77b13e8b 100644 --- a/web/package.json +++ b/web/package.json @@ -37,7 +37,8 @@ "redux-logger": "^3.0.6", "redux-mock-store": "^1.3.0", "redux-thunk": "^2.2.0", - "shallowequal": "^1.0.2" + "shallowequal": "^1.0.2", + "stable": "^0.1.6" }, "devDependencies": { "babel-core": "^6.26.0", diff --git a/web/src/js/components/FlowTable/FlowColumns.jsx b/web/src/js/components/FlowTable/FlowColumns.jsx index 02a4fba1..e60ed487 100644 --- a/web/src/js/components/FlowTable/FlowColumns.jsx +++ b/web/src/js/components/FlowTable/FlowColumns.jsx @@ -119,7 +119,7 @@ export function TimeColumn({ flow }) { return ( <td className="col-time"> {flow.response ? ( - formatTimeDelta(1000 * (flow.response.timestamp_end - flow.server_conn.timestamp_start)) + formatTimeDelta(1000 * (flow.response.timestamp_end - flow.request.timestamp_start)) ) : ( '...' )} diff --git a/web/src/js/ducks/utils/store.js b/web/src/js/ducks/utils/store.js index ac272650..ad2242ee 100644 --- a/web/src/js/ducks/utils/store.js +++ b/web/src/js/ducks/utils/store.js @@ -1,3 +1,5 @@ +import stable from 'stable' + export const SET_FILTER = 'LIST_SET_FILTER' export const SET_SORT = 'LIST_SET_SORT' export const ADD = 'LIST_ADD' @@ -35,7 +37,7 @@ export default function reduce(state = defaultState, action) { switch (action.type) { case SET_FILTER: - view = list.filter(action.filter).sort(action.sort) + view = stable(list.filter(action.filter), action.sort) viewIndex = {} view.forEach((item, index) => { viewIndex[item.id] = index @@ -43,7 +45,7 @@ export default function reduce(state = defaultState, action) { break case SET_SORT: - view = [...view].sort(action.sort) + view = stable([...view], action.sort) viewIndex = {} view.forEach((item, index) => { viewIndex[item.id] = index diff --git a/web/yarn.lock b/web/yarn.lock index aa5ae85f..1930fded 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -5449,6 +5449,10 @@ sshpk@^1.7.0: jsbn "~0.1.0"
tweetnacl "~0.14.0"
+stable@^0.1.6:
+ version "0.1.6"
+ resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.6.tgz#910f5d2aed7b520c6e777499c1f32e139fdecb10"
+
statuses@1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e"
|