From f2792e5001688a2d81822881510c1693ecf00d89 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 9 Mar 2023 13:59:25 -0500 Subject: [PATCH 1/2] Add require-api-key-secret --- README.md | 1 + VERSION | 2 +- libretranslate/app.py | 49 +++++++++++++++++++----- libretranslate/default_values.py | 5 +++ libretranslate/flood.py | 19 ++++++++- libretranslate/main.py | 6 +++ libretranslate/templates/app.js.template | 19 ++++++++- 7 files changed, 88 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d29e151..1f7a084 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ docker-compose -f docker-compose.cuda.yml up -d --build | --api-keys-remote | Use this remote endpoint to query for valid API keys instead of using the local database | `Use local API key database` | LT_API_KEYS_REMOTE | | --get-api-key-link | Show a link in the UI where to direct users to get an API key | `Don't show a link` | LT_GET_API_KEY_LINK | | --require-api-key-origin | Require use of an API key for programmatic access to the API, unless the request origin matches this domain | `No restrictions on domain origin` | LT_REQUIRE_API_KEY_ORIGIN | +| --require-api-key-secret | Require use of an API key for programmatic access to the API, unless the client also sends a secret match | `No secrets required` | LT_REQUIRE_API_KEY_SECRET | | --load-only | Set available languages | `all from argostranslate` | LT_LOAD_ONLY | | --threads | Set number of threads | `4` | LT_THREADS | | --suggestions | Allow user suggestions | `False` | LT_SUGGESTIONS | diff --git a/VERSION b/VERSION index d4c4950..0c00f61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.9 +1.3.10 diff --git a/libretranslate/app.py b/libretranslate/app.py index 21f9095..3bfb3ba 100644 --- a/libretranslate/app.py +++ b/libretranslate/app.py @@ -6,6 +6,7 @@ import uuid from functools import wraps from html import unescape from timeit import default_timer +from datetime import datetime import argostranslatefiles from argostranslatefiles import get_supported_formats @@ -54,6 +55,15 @@ def get_req_api_key(): return ak +def get_req_secret(): + if request.is_json: + json = get_json_dict(request) + ak = json.get("secret") + else: + ak = request.values.get("secret") + + return ak + def get_json_dict(request): d = request.get_json() @@ -233,18 +243,28 @@ def create_app(args): if args.api_keys: ak = get_req_api_key() - if ( - ak and api_keys_db.lookup(ak) is None - ): + if ak and api_keys_db.lookup(ak) is None: abort( 403, description=_("Invalid API key"), ) - elif ( - args.require_api_key_origin - and api_keys_db.lookup(ak) is None - and not re.match(args.require_api_key_origin, request.headers.get("Origin", "")) - ): + else: + need_key = False + key_missing = api_keys_db.lookup(ak) is None + + if (args.require_api_key_origin + and key_missing + and not re.match(args.require_api_key_origin, request.headers.get("Origin", "")) + ): + need_key = True + + if (args.require_api_key_secret + and key_missing + and not flood.secret_match(get_req_secret()) + ): + need_key = True + + if need_key: description = _("Please contact the server operator to get an API key") if args.get_api_key_link: description = _("Visit %(url)s to get an API key", url=args.get_api_key_link) @@ -323,9 +343,18 @@ def create_app(args): if args.disable_web_ui: abort(404) - return Response(render_template("app.js.template", + response = Response(render_template("app.js.template", url_prefix=args.url_prefix, - get_api_key_link=args.get_api_key_link), content_type='application/javascript; charset=utf-8') + get_api_key_link=args.get_api_key_link, + api_secret=flood.get_current_secret() if args.require_api_key_secret else ""), content_type='application/javascript; charset=utf-8') + + if args.require_api_key_secret: + response.headers['Last-Modified'] = datetime.now() + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '-1' + + return response @bp.get("/languages") @limiter.exempt diff --git a/libretranslate/default_values.py b/libretranslate/default_values.py index e3e9113..a9d007b 100644 --- a/libretranslate/default_values.py +++ b/libretranslate/default_values.py @@ -131,6 +131,11 @@ _default_options_objects = [ 'default_value': '', 'value_type': 'str' }, + { + 'name': 'REQUIRE_API_KEY_SECRET', + 'default_value': False, + 'value_type': 'bool' + }, { 'name': 'LOAD_ONLY', 'default_value': None, diff --git a/libretranslate/flood.py b/libretranslate/flood.py index 088cf64..9d2d711 100644 --- a/libretranslate/flood.py +++ b/libretranslate/flood.py @@ -1,11 +1,16 @@ import atexit +import random +import string from apscheduler.schedulers.background import BackgroundScheduler +def generate_secret(): + return ''.join(random.choices(string.ascii_uppercase + string.digits, k=7)) + banned = {} active = False threshold = -1 - +secrets = [generate_secret(), generate_secret()] def forgive_banned(): global banned @@ -22,6 +27,16 @@ def forgive_banned(): for ip in clear_list: del banned[ip] +def rotate_secrets(): + global secrets + secrets[0] = secrets[1] + secrets[1] = generate_secret() + +def secret_match(s): + return s in secrets + +def get_current_secret(): + return secrets[1] def setup(violations_threshold=100): global active @@ -32,6 +47,8 @@ def setup(violations_threshold=100): scheduler = BackgroundScheduler() scheduler.add_job(func=forgive_banned, trigger="interval", minutes=30) + scheduler.add_job(func=rotate_secrets, trigger="interval", minutes=30) + scheduler.start() # Shut down the scheduler when exiting the app diff --git a/libretranslate/main.py b/libretranslate/main.py index aa6aabf..5a58885 100644 --- a/libretranslate/main.py +++ b/libretranslate/main.py @@ -120,6 +120,12 @@ def get_args(): default=DEFARGS['REQUIRE_API_KEY_ORIGIN'], help="Require use of an API key for programmatic access to the API, unless the request origin matches this domain", ) + parser.add_argument( + "--require-api-key-secret", + default=DEFARGS['REQUIRE_API_KEY_SECRET'], + action="store_true", + help="Require use of an API key for programmatic access to the API, unless the client also sends a secret match", + ) parser.add_argument( "--load-only", type=operator.methodcaller("split", ","), diff --git a/libretranslate/templates/app.js.template b/libretranslate/templates/app.js.template index ef82091..4992d5b 100644 --- a/libretranslate/templates/app.js.template +++ b/libretranslate/templates/app.js.template @@ -39,7 +39,9 @@ document.addEventListener('DOMContentLoaded', function(){ loadingFileTranslation: false, translatedFileUrl: false, filesTranslation: true, - frontendTimeout: 500 + frontendTimeout: 500, + + apiSecret: "{{ api_secret }}" }, mounted: function() { const self = this; @@ -234,11 +236,19 @@ document.addEventListener('DOMContentLoaded', function(){ data.append("target", self.targetLang); data.append("format", self.isHtml ? "html" : "text"); data.append("api_key", localStorage.getItem("api_key") || ""); + if (self.apiSecret) data.append("secret", self.apiSecret); request.open('POST', BaseUrl + '/translate', true); request.onload = function() { try{ + {% if api_secret != "" %} + if (this.status === 403){ + window.location.reload(true); + return; + } + {% endif %} + var res = JSON.parse(this.response); // Success! if (res.translatedText !== undefined){ @@ -365,12 +375,19 @@ document.addEventListener('DOMContentLoaded', function(){ data.append("source", this.sourceLang); data.append("target", this.targetLang); data.append("api_key", localStorage.getItem("api_key") || ""); + if (self.apiSecret) data.append("secret", self.apiSecret); this.loadingFileTranslation = true translateFileRequest.onload = function() { if (translateFileRequest.readyState === 4 && translateFileRequest.status === 200) { try{ + {% if api_secret != "" %} + if (this.status === 403){ + window.location.reload(true); + return; + } + {% endif %} self.loadingFileTranslation = false; let res = JSON.parse(this.response); From b59b82cfb074ae3d5755ef94c47099c5cfab223e Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Thu, 9 Mar 2023 14:05:09 -0500 Subject: [PATCH 2/2] Fix last-modified header response --- libretranslate/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libretranslate/app.py b/libretranslate/app.py index 3bfb3ba..d6d7ec0 100644 --- a/libretranslate/app.py +++ b/libretranslate/app.py @@ -18,6 +18,7 @@ from flask_session import Session from translatehtml import translate_html from werkzeug.utils import secure_filename from werkzeug.exceptions import HTTPException +from werkzeug.http import http_date from flask_babel import Babel from libretranslate import flood, remove_translated_files, security @@ -349,7 +350,7 @@ def create_app(args): api_secret=flood.get_current_secret() if args.require_api_key_secret else ""), content_type='application/javascript; charset=utf-8') if args.require_api_key_secret: - response.headers['Last-Modified'] = datetime.now() + response.headers['Last-Modified'] = http_date(datetime.now()) response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0, max-age=0' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '-1'