diff --git a/Dockerfile b/Dockerfile index f7a56182b..d77b183d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,3 +46,6 @@ FROM release AS operation # don't run the server itself, just start up the environment and assume we'll exec things from the outside CMD sleep infinity + +# Turn off the rate limiter +ENV DBG_LIMITER_DISABLED=true diff --git a/files/__main__.py b/files/__main__.py index 67002265a..f0085a775 100644 --- a/files/__main__.py +++ b/files/__main__.py @@ -137,6 +137,9 @@ def get_remote_addr(): with app.app_context(): return request.headers.get('X-Real-IP', default='127.0.0.1') +app.config['RATE_LIMITER_ENABLED'] = not bool_from_string(environ.get('DBG_LIMITER_DISABLED', False)) +if not app.config['RATE_LIMITER_ENABLED']: + print("Rate limiter disabled in debug mode!") limiter = Limiter( app, key_func=get_remote_addr, @@ -144,6 +147,7 @@ limiter = Limiter( application_limits=["10/second;200/minute;5000/hour;10000/day"], storage_uri=environ.get("REDIS_URL", "redis://localhost"), auto_check=False, + enabled=app.config['RATE_LIMITER_ENABLED'], ) Base = declarative_base() diff --git a/files/routes/login.py b/files/routes/login.py index 81eca7626..b32412cd3 100644 --- a/files/routes/login.py +++ b/files/routes/login.py @@ -261,8 +261,9 @@ def sign_up_post(v): return redirect(f"/signup?{urlencode(args)}") - if now - int(form_timestamp) < 5: - return signup_error("There was a problem. Please try again.") + if app.config['RATE_LIMITER_ENABLED']: + if now - int(form_timestamp) < 5: + return signup_error("There was a problem. Please try again.") if not hmac.compare_digest(correct_formkey, form_formkey): return signup_error("There was a problem. Please try again.") diff --git a/files/tests/conftest.py b/files/tests/conftest.py new file mode 100644 index 000000000..8a83f4ddb --- /dev/null +++ b/files/tests/conftest.py @@ -0,0 +1,4 @@ + +import pytest + +from .fixture_accounts import accounts diff --git a/files/tests/fixture_accounts.py b/files/tests/fixture_accounts.py new file mode 100644 index 000000000..7795cd943 --- /dev/null +++ b/files/tests/fixture_accounts.py @@ -0,0 +1,43 @@ + +from . import util + +from bs4 import BeautifulSoup +from files.__main__ import app +from functools import lru_cache +import pytest +from time import time, sleep + +class AccountsFixture: + @lru_cache(maxsize=None) + def client_for_account(self, name = "default"): + + client = app.test_client() + + signup_get_response = client.get("/signup") + assert signup_get_response.status_code == 200 + soup = BeautifulSoup(signup_get_response.text, 'html.parser') + # these hidden input values seem to be used for anti-bot purposes and need to be submitted + form_timestamp = next(tag for tag in soup.find_all("input") if tag.get("name") == "now").get("value") + + username = f"test-{name}-{str(round(time()))}" + print(f"Signing up as {username}") + + signup_post_response = client.post("/signup", data={ + "username": username, + "password": "password", + "password_confirm": "password", + "email": "", + "formkey": util.formkey_from(signup_get_response.text), + "now": form_timestamp + }) + assert signup_post_response.status_code == 302 + assert "error" not in signup_post_response.location + + return client + + def logged_off(self): + return app.test_client() + +@pytest.fixture(scope="session") +def accounts(): + return AccountsFixture() diff --git a/files/tests/test_basic.py b/files/tests/test_basic.py new file mode 100644 index 000000000..676cd78b0 --- /dev/null +++ b/files/tests/test_basic.py @@ -0,0 +1,54 @@ + +from . import fixture_accounts +from . import util + +def test_rules(accounts): + response = accounts.logged_off().get("/rules") + assert response.status_code == 200 + assert response.text.startswith("") + + +def test_post_and_comment(accounts): + client = accounts.client_for_account() + + # get our formkey + submit_get_response = client.get("/submit") + assert submit_get_response.status_code == 200 + + # make the post + post_title = util.generate_text() + post_body = util.generate_text() + submit_post_response = client.post("/submit", data={ + "title": post_title, + "body": post_body, + "formkey": util.formkey_from(submit_get_response.text), + }) + + assert submit_post_response.status_code == 200 + assert post_title in submit_post_response.text + assert post_body in submit_post_response.text + + # verify it actually got posted + root_response = client.get("/") + assert root_response.status_code == 200 + assert post_title in root_response.text + assert post_body in root_response.text + + # yank the ID out + post = util.ItemData.from_html(submit_post_response.text) + + # post a comment child + comment_body = util.generate_text() + submit_comment_response = client.post("/comment", data={ + "parent_fullname": post.id_full, + "parent_level": 1, + "submission": post.id, + "body": comment_body, + "formkey": util.formkey_from(submit_post_response.text), + }) + assert submit_comment_response.status_code == 200 + + # verify it actually got posted + post_response = client.get(post.url) + assert post_response.status_code == 200 + assert comment_body in post_response.text diff --git a/files/tests/test_e2e.py b/files/tests/test_e2e.py deleted file mode 100644 index 6b9e394ae..000000000 --- a/files/tests/test_e2e.py +++ /dev/null @@ -1,57 +0,0 @@ -from bs4 import BeautifulSoup -from time import time, sleep -from files.__main__ import app - -# these tests require `docker-compose up` first - -def test_rules(): - response = app.test_client().get("/rules") - assert response.status_code == 200 - assert response.text.startswith("") - - -def test_signup_and_post(): - print("\nTesting signup and posting flow") - client = app.test_client() - with client: # this keeps the session between requests, which we need - signup_get_response = client.get("/signup") - assert signup_get_response.status_code == 200 - soup = BeautifulSoup(signup_get_response.text, 'html.parser') - # these hidden input values seem to be used for anti-bot purposes and need to be submitted - formkey = next(tag for tag in soup.find_all("input") if tag.get("name") == "formkey").get("value") - form_timestamp = next(tag for tag in soup.find_all("input") if tag.get("name") == "now").get("value") - - sleep(5) # too-fast submissions are rejected (bot check?) - username = "testuser" + str(round(time())) - signup_post_response = client.post("/signup", data={ - "username": username, - "password": "password", - "password_confirm": "password", - "email": "", - "formkey": formkey, - "now": form_timestamp - }) - print(f"Signing up as {username}") - assert signup_post_response.status_code == 302 - assert "error" not in signup_post_response.location - - # we should now be logged in and able to post - submit_get_response = client.get("/submit") - assert submit_get_response.status_code == 200 - - submit_post_response = client.post("/submit", data={ - "title": "my_cool_post", - "body": "hey_guys", - }) - assert submit_post_response.status_code == 302 - post_render_result = client.get(submit_post_response.location) - assert "my_cool_post" in post_render_result.text - assert "hey_guys" in post_render_result.text - - - - - - - - diff --git a/files/tests/util.py b/files/tests/util.py new file mode 100644 index 000000000..efc990211 --- /dev/null +++ b/files/tests/util.py @@ -0,0 +1,36 @@ + +from bs4 import BeautifulSoup +import random +import re +import string + +def formkey_from(text): + soup = BeautifulSoup(text, 'html.parser') + formkey = next(tag for tag in soup.find_all("input") if tag.get("name") == "formkey").get("value") + return formkey + +# not cryptographically secure, deal with it +def generate_text(): + return ''.join(random.choices(string.ascii_lowercase, k=40)) + +# this is meant to be a utility class that stores post and comment references so you can use them in other calls +# it's pretty barebones and will probably be fleshed out +class ItemData: + id = None + id_full = None + url = None + + @staticmethod + def from_html(text): + soup = BeautifulSoup(text, 'html.parser') + url = soup.find("meta", property="og:url")["content"] + + match = re.search(r'/post/(\d+)/', url) + if match is None: + return None + + result = ItemData() + result.id = match.group(1) # this really should get yanked out of the JS, not the URL + result.id_full = f"t2_{result.id}" + result.url = url + return result