Skip to content

Commit 92ccdd7

Browse files
committed
feat: adding mechanisms to avoid api abuse
1 parent 983b87a commit 92ccdd7

10 files changed

Lines changed: 442 additions & 16 deletions

File tree

Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ COPY . .
1212
# Make port 8000 available to the world outside this container
1313
EXPOSE 8000
1414

15-
# Run app.py when the container launches
15+
# Run app.py when the container launches; bind/workers/timeout/logging come
16+
# from gunicorn.conf.py (overridable via WEB_CONCURRENCY / GUNICORN_TIMEOUT).
1617
# In case of wish the dev server use CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"]
17-
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
18+
CMD ["gunicorn", "app:app"]

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,30 @@ in the Swagger UI at <http://localhost:8000/docs/>.
143143

144144
<p align="right">(<a href="#top">back to top</a>)</p>
145145

146+
## Abuse controls & configuration
147+
148+
The API ships with per-IP rate limiting, a per-operation time budget, upload size limits and a
149+
result cache, all configurable through environment variables (e.g. `docker run -e FLAMAPY_...`):
150+
151+
| Env var | Default | Meaning |
152+
|---|---|---|
153+
| `FLAMAPY_MAX_CONTENT_LENGTH` | `16777216` | Max upload size in bytes; larger requests get **413** |
154+
| `FLAMAPY_RATELIMIT_ENABLED` | `true` | Turn rate limiting on/off |
155+
| `FLAMAPY_RATELIMIT_DEFAULT` | `60 per minute` | Per-IP limit for cheap operations |
156+
| `FLAMAPY_RATELIMIT_EXPENSIVE` | `10 per minute` | Per-IP limit for enumeration/solver-heavy operations (`configurations`, `configurations_number`, `sampling`, …); exceeding a limit returns **429** |
157+
| `FLAMAPY_RATELIMIT_STORAGE_URI` | `memory://` | Limiter storage; use `redis://host:6379` when running several gunicorn workers so they share counters |
158+
| `FLAMAPY_OPERATION_TIMEOUT` | `60` | Seconds an operation may run before it is killed and **504** is returned; `0` disables |
159+
| `FLAMAPY_CACHE_TTL` | `3600` | Seconds a result stays cached (same model + operation + arguments); `0` disables. Responses carry an `X-Cache: HIT|MISS` header |
160+
| `FLAMAPY_CACHE_MAXSIZE` | `128` | Max cached results per worker |
161+
| `FLAMAPY_TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so rate limits see the real client IP (`X-Forwarded-For`) |
162+
| `WEB_CONCURRENCY` | `2` | gunicorn worker count (Docker image) |
163+
| `GUNICORN_TIMEOUT` | `120` | gunicorn hard worker timeout; keep it above `FLAMAPY_OPERATION_TIMEOUT` |
164+
165+
Every request is logged to stdout (client IP, path, status, duration, upload size, cache result),
166+
so `docker logs` is enough to spot heavy users.
167+
168+
<p align="right">(<a href="#top">back to top</a>)</p>
169+
146170
## API Documentation
147171

148172
All documentation is registered with Swagger UI and OAS 3.0, served at

app.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,69 @@
1-
from flask import Flask, Response
1+
import logging
2+
import time
3+
4+
from flask import Flask, Response, g, jsonify, request
25
from flask_cors import CORS
36
from flasgger import Swagger
7+
from werkzeug.exceptions import HTTPException
8+
from werkzeug.middleware.proxy_fix import ProxyFix
49

510
# Importing routes
611
from flamapy.interfaces.rest.operations_routes import operations_bp
12+
from flamapy.interfaces.rest.config import load_config
13+
from flamapy.interfaces.rest.extensions import limiter, result_cache
714

815
# Creating the app and configuring the cors
916
app = Flask(__name__)
1017
CORS(app)
11-
# Reject oversized uploads early (feature model files are small); guards against
12-
# memory-exhaustion DoS via huge multipart bodies.
13-
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB
18+
# Abuse controls (upload size, rate limits, operation timeout, result cache);
19+
# every value is overridable through FLAMAPY_* env vars.
20+
app.config.update(load_config())
21+
22+
if app.config['TRUST_PROXY']:
23+
# Honor X-Forwarded-For so per-IP rate limits see the real client address
24+
# when the API runs behind a reverse proxy.
25+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # type: ignore[method-assign]
26+
27+
limiter.init_app(app)
28+
result_cache.init_app(app)
29+
30+
# One structured line per request on stdout (Docker-friendly).
31+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
32+
request_logger = logging.getLogger('flamapy.rest.requests')
33+
34+
35+
@app.before_request
36+
def _start_timer() -> None:
37+
g.start_time = time.monotonic()
38+
39+
40+
@app.after_request
41+
def _log_request(response: Response) -> Response:
42+
duration_ms = (time.monotonic() - g.get('start_time', time.monotonic())) * 1000
43+
request_logger.info(
44+
'remote=%s method=%s path=%s status=%s duration_ms=%.1f bytes_in=%s cache=%s',
45+
request.remote_addr,
46+
request.method,
47+
request.path,
48+
response.status_code,
49+
duration_ms,
50+
request.content_length or 0,
51+
response.headers.get('X-Cache', '-'),
52+
)
53+
return response
54+
55+
56+
@app.errorhandler(HTTPException)
57+
def _json_http_error(error: HTTPException) -> tuple[Response, int]:
58+
# Covers 400/404/413/429/504...; keeps API errors as JSON instead of HTML.
59+
return jsonify(error=error.description), error.code or 500
60+
61+
62+
@app.errorhandler(Exception)
63+
def _json_unexpected_error(error: Exception) -> tuple[Response, int]:
64+
request_logger.exception('Unhandled error on %s: %s', request.path, error)
65+
return jsonify(error='Internal server error'), 500
66+
1467

1568
#Now we are configuring the self generation of swagger by means of flasgger
1669
config = {

flamapy/interfaces/rest/config.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import os
2+
from typing import Any
3+
4+
5+
def _env_bool(name: str, default: bool) -> bool:
6+
raw = os.environ.get(name)
7+
if raw is None:
8+
return default
9+
return raw.strip().lower() in ('1', 'true', 'yes', 'on')
10+
11+
12+
def _env_int(name: str, default: int) -> int:
13+
raw = os.environ.get(name)
14+
if raw is None or raw.strip() == '':
15+
return default
16+
return int(raw)
17+
18+
19+
def load_config() -> dict[str, Any]:
20+
"""Abuse-control settings, every one overridable through an env var so the
21+
Docker image stays a single self-contained container."""
22+
return {
23+
'MAX_CONTENT_LENGTH': _env_int('FLAMAPY_MAX_CONTENT_LENGTH', 16 * 1024 * 1024),
24+
'RATELIMIT_ENABLED': _env_bool('FLAMAPY_RATELIMIT_ENABLED', True),
25+
'RATELIMIT_STORAGE_URI': os.environ.get('FLAMAPY_RATELIMIT_STORAGE_URI', 'memory://'),
26+
'RATELIMIT_DEFAULT_OPERATION': os.environ.get(
27+
'FLAMAPY_RATELIMIT_DEFAULT', '60 per minute'
28+
),
29+
'RATELIMIT_EXPENSIVE_OPERATION': os.environ.get(
30+
'FLAMAPY_RATELIMIT_EXPENSIVE', '10 per minute'
31+
),
32+
'OPERATION_TIMEOUT': _env_int('FLAMAPY_OPERATION_TIMEOUT', 60),
33+
'CACHE_TTL': _env_int('FLAMAPY_CACHE_TTL', 3600),
34+
'CACHE_MAXSIZE': _env_int('FLAMAPY_CACHE_MAXSIZE', 128),
35+
'TRUST_PROXY': _env_bool('FLAMAPY_TRUST_PROXY', False),
36+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import threading
2+
from typing import Any, Optional
3+
4+
from cachetools import TTLCache
5+
from flask import Flask, current_app
6+
from flask_limiter import Limiter
7+
from flask_limiter.util import get_remote_address
8+
9+
10+
def default_operation_limit() -> str:
11+
return current_app.config['RATELIMIT_DEFAULT_OPERATION']
12+
13+
14+
def expensive_operation_limit() -> str:
15+
return current_app.config['RATELIMIT_EXPENSIVE_OPERATION']
16+
17+
18+
# Storage URI and enabled flag are picked up from app.config (RATELIMIT_*)
19+
# during init_app; per-route limits are attached in operations_routes.
20+
limiter = Limiter(key_func=get_remote_address)
21+
22+
23+
class ResultCache:
24+
"""Per-process TTL cache for operation results. The cache object is created
25+
on init_app so its size/TTL follow the app configuration; a TTL of 0
26+
disables caching entirely."""
27+
28+
def __init__(self) -> None:
29+
self._cache: Optional[TTLCache[str, Any]] = None
30+
self._lock = threading.Lock()
31+
32+
def init_app(self, app: Flask) -> None:
33+
ttl = app.config['CACHE_TTL']
34+
if ttl > 0:
35+
self._cache = TTLCache(maxsize=app.config['CACHE_MAXSIZE'], ttl=ttl)
36+
else:
37+
self._cache = None
38+
39+
def get(self, key: str) -> Any:
40+
if self._cache is None:
41+
return None
42+
with self._lock:
43+
return self._cache.get(key)
44+
45+
def set(self, key: str, value: Any) -> None:
46+
if self._cache is None:
47+
return
48+
with self._lock:
49+
self._cache[key] = value
50+
51+
52+
result_cache = ResultCache()

flamapy/interfaces/rest/operations_routes.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,46 @@
11
import os
2+
import hashlib
23
import inspect
34
import json
45
import shutil
56
import tempfile
67
import typing
78
from typing import Any, Callable
89

9-
from flask import Blueprint, request, jsonify, abort
10+
from flask import Blueprint, Response, current_app, request, jsonify, abort
1011
from werkzeug.datastructures import FileStorage
1112
from werkzeug.utils import secure_filename
1213
from flamapy.interfaces.python.flamapy_feature_model import FLAMAFeatureModel, Backend
13-
from flamapy.core.exceptions import FlamaException
1414
from flamapy.metamodels.configuration_metamodel.models import Configuration
1515

16+
from flamapy.interfaces.rest.extensions import (
17+
limiter,
18+
result_cache,
19+
default_operation_limit,
20+
expensive_operation_limit,
21+
)
22+
from flamapy.interfaces.rest.runner import ModelParseError, OperationTimeout, run_operation
23+
1624

1725
operations_bp = Blueprint('operations_bp', __name__, url_prefix='/api/v1/operations')
1826

27+
# Operations that enumerate or repeatedly invoke a solver; they get the stricter
28+
# rate-limit tier because a single call can monopolize a CPU for a long time.
29+
_EXPENSIVE_OPERATIONS = {
30+
'configurations',
31+
'configurations_number',
32+
'configurations_with_n_features',
33+
'conflict',
34+
'diagnosis',
35+
'feature_inclusion_probability',
36+
'homogeneity',
37+
'product_distribution',
38+
'sampling',
39+
'unique_features',
40+
'variability',
41+
'variant_features',
42+
}
43+
1944
# Backward-compatible multipart field names for parameters that the API exposed
2045
# before the dispatcher became generic. Any other parameter derives its field
2146
# name from the parameter itself (file params drop the trailing "_path").
@@ -116,6 +141,26 @@ def _resolve_kwargs(operation: Any, work_dir: str) -> dict[str, Any]:
116141
return kwargs
117142

118143

144+
def _cache_key(
145+
operation_name: str, operation: Any, model_path: str, kwargs: dict[str, Any]
146+
) -> str:
147+
"""Digest of everything that determines the result: the operation, the model
148+
contents and every argument (file arguments by content, not by temp path)."""
149+
file_params = {p.name for p in _operation_params(operation) if _is_file_param(p)}
150+
digest = hashlib.sha256()
151+
digest.update(operation_name.encode())
152+
with open(model_path, 'rb') as model_file:
153+
digest.update(model_file.read())
154+
for name in sorted(kwargs):
155+
digest.update(b'\x00' + name.encode() + b'\x00')
156+
if name in file_params:
157+
with open(kwargs[name], 'rb') as uploaded_file:
158+
digest.update(uploaded_file.read())
159+
else:
160+
digest.update(repr(kwargs[name]).encode())
161+
return digest.hexdigest()
162+
163+
119164
def _api_call(operation_name: str) -> Any:
120165
uploaded_model = request.files.get('model')
121166
if uploaded_model is None or not uploaded_model.filename:
@@ -126,19 +171,34 @@ def _api_call(operation_name: str) -> Any:
126171
work_dir = tempfile.mkdtemp(prefix='flamapy_')
127172
try:
128173
model_path = _save_upload(uploaded_model, work_dir)
174+
operation = getattr(FLAMAFeatureModel, operation_name)
175+
kwargs = _resolve_kwargs(operation, work_dir)
176+
177+
key = _cache_key(operation_name, operation, model_path, kwargs)
178+
payload = result_cache.get(key)
179+
if payload is not None:
180+
response: Response = jsonify(payload)
181+
response.headers['X-Cache'] = 'HIT'
182+
return response
183+
129184
try:
130-
fm = FLAMAFeatureModel(model_path)
131-
except FlamaException:
185+
result = run_operation(
186+
model_path, operation_name, kwargs, current_app.config['OPERATION_TIMEOUT']
187+
)
188+
except ModelParseError:
132189
abort(400, "The uploaded model could not be parsed")
133-
operation = getattr(fm, operation_name)
134-
kwargs = _resolve_kwargs(operation, work_dir)
135-
result = operation(**kwargs)
190+
except OperationTimeout as exc:
191+
abort(504, str(exc))
136192
finally:
137193
shutil.rmtree(work_dir, ignore_errors=True)
138194

139195
if result is None:
140196
return jsonify(error='Not valid result'), 404
141-
return jsonify(json.loads(json.dumps(result, cls=CustomJSONEncoder)))
197+
payload = json.loads(json.dumps(result, cls=CustomJSONEncoder))
198+
result_cache.set(key, payload)
199+
response = jsonify(payload)
200+
response.headers['X-Cache'] = 'MISS'
201+
return response
142202

143203

144204
def extract_docstring_with_swagger_info(method: Any) -> str:
@@ -189,9 +249,12 @@ def route_function() -> Any:
189249

190250

191251
# Introspect FLAMAFeatureModel to expose every public method as a POST route,
192-
# generating its Swagger spec from the method signature.
252+
# generating its Swagger spec from the method signature. Limits are passed as
253+
# callables so they read the app config of whichever app the blueprint joins.
193254
for name, method in inspect.getmembers(FLAMAFeatureModel, predicate=inspect.isfunction):
194255
if name.startswith('_'):
195256
continue
196257
docstring = extract_docstring_with_swagger_info(method)
197-
operations_bp.route(f'/{name}', methods=['POST'])(create_route(name, docstring))
258+
limit = expensive_operation_limit if name in _EXPENSIVE_OPERATIONS else default_operation_limit
259+
route_view = limiter.limit(limit)(create_route(name, docstring))
260+
operations_bp.route(f'/{name}', methods=['POST'])(route_view)

0 commit comments

Comments
 (0)