Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ca1ae0e
Merge pull request #69 from MariaEduardaBatt/feat/25-enforce-15s-comp…
LucaS4nt0s Jun 13, 2026
de3f6b4
Merge pull request #71 from MariaEduardaBatt/feat/27-xterm-terminal
LucaS4nt0s Jun 13, 2026
eb39472
feat(backend): implement PtyExecutionStrategy (#29)
LucaS4nt0s Jun 13, 2026
9f689f1
Merge pull request #73 from MariaEduardaBatt/feat/29-pty-execution-st…
LucaS4nt0s Jun 13, 2026
15616c6
feat(backend): bridge websocket and pty streams (#30)
LucaS4nt0s Jun 13, 2026
815b2c1
Merge pull request #74 from MariaEduardaBatt/feat/30-bridge-websocket…
LucaS4nt0s Jun 13, 2026
a03038a
ajuste no docker-compose para subir direto com o .env no diretório raiz
LucaS4nt0s Jun 13, 2026
1c58e16
feat(backend): bridge websocket and pty streams
MariaEduardaBatt Jun 15, 2026
606bb9d
fix(nginx): forward Sec-WebSocket-Protocol header to backend
MariaEduardaBatt Jun 15, 2026
06f43dd
fix(auth): support ES256 Supabase tokens
MariaEduardaBatt Jun 15, 2026
21a112a
fix(auth): use JWKS to verify ES256 Supabase tokens
MariaEduardaBatt Jun 15, 2026
4e5fcc9
fix(config): rename env var SUPABASE_JWT_SECRET -> JWT_SECRET
MariaEduardaBatt Jun 15, 2026
b37787b
fix(ws): accept client subprotocol in WebSocket handshake
MariaEduardaBatt Jun 15, 2026
5fd25d8
fix(auth): robust JWKS with ES256/HS256 fallback + logging
MariaEduardaBatt Jun 15, 2026
99b0fa2
fix(auth): disable aud verification, only fallback HS256 for HS256 to…
MariaEduardaBatt Jun 15, 2026
fcb01a4
chore: remove test files
MariaEduardaBatt Jun 15, 2026
4495604
Merge pull request #75 from MariaEduardaBatt/feat/30
MariaEduardaBatt Jun 15, 2026
b242b5a
fix(backend): keep execution loop alive on ws.receive timeout
MariaEduardaBatt Jun 15, 2026
9dde930
Merge pull request #76 from MariaEduardaBatt/feat/31
MariaEduardaBatt Jun 15, 2026
937486d
feat(backend): implement websocket protocol events (#32)
MariaEduardaBatt Jun 15, 2026
9aa6ffd
Merge pull request #77 from MariaEduardaBatt/feat/32
MariaEduardaBatt Jun 15, 2026
c2a0dc0
feat(frontend): wire stop action to websocket (#33)
MariaEduardaBatt Jun 15, 2026
823e8f8
fix(frontend): reset Run button after stop/exit
MariaEduardaBatt Jun 15, 2026
e740a5c
Merge pull request #78 from MariaEduardaBatt/feat/33
MariaEduardaBatt Jun 15, 2026
e07e57d
feat(backend): enforce 10s execution timeout
MariaEduardaBatt Jun 15, 2026
fc2a6a7
fix(backend): remove gevent-websocket to fix WebSocket 400
MariaEduardaBatt Jun 15, 2026
6d51a0b
fix(backend): revert read_only to False to fix put_archive
MariaEduardaBatt Jun 15, 2026
1fde6fc
Merge pull request #79 from MariaEduardaBatt/feat/33
MariaEduardaBatt Jun 15, 2026
ee034f4
feat(devops): set docker hard stop timeout to 12s (#35)
MariaEduardaBatt Jun 15, 2026
be53fe5
Merge pull request #80 from MariaEduardaBatt/feat/35-docker-hard-stop…
MariaEduardaBatt Jun 15, 2026
2a89255
feat(security): harden sandbox docker flags (#36)
MariaEduardaBatt Jun 15, 2026
14eeca4
feat(backend): add per-user execution rate limit (#37)
MariaEduardaBatt Jun 17, 2026
84f685c
feat(backend): use flask-limiter + Redis for rate limit shared across…
MariaEduardaBatt Jun 17, 2026
9ca1829
fix(backend): fallback to in-memory when Redis is unavailable
MariaEduardaBatt Jun 17, 2026
fc11df1
revert: remove Redis, keep flask-limiter with in-memory only
MariaEduardaBatt Jun 17, 2026
1b0b110
fix(backend): link binary with -static for qemu compatibility
MariaEduardaBatt Jun 17, 2026
cdb3faa
fix(backend): use put_archive instead of bind mount for Docker sandbox
MariaEduardaBatt Jun 17, 2026
7001ac6
fix(backend): remove read_only to allow put_archive
MariaEduardaBatt Jun 17, 2026
3178346
fix(backend): embed binary as base64 in container command
MariaEduardaBatt Jun 17, 2026
93032a1
chore(devops): reduce gunicorn to 1 worker for in-memory rate limit
MariaEduardaBatt Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
# Python
__pycache__/
*.pyc
*.egg-info/

# Vite
frontend/.vite/deps/
5 changes: 3 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ RUN pip install --break-system-packages --no-cache-dir -r requirements.txt

COPY backend/pyproject.toml backend/wsgi.py ./
COPY backend/src/ src/
RUN pip install --break-system-packages --no-cache-dir -e .

EXPOSE 5000

CMD ["gunicorn", "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \
"-w", "4", "-b", "0.0.0.0:5000", "wsgi:app"]
CMD ["gunicorn", "-k", "gevent", \
"-w", "1", "-b", "0.0.0.0:5000", "wsgi:app"]
1 change: 0 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ dependencies = [
"pytest>=8.0.0",
"flask-sock>=0.3.0",
"gevent>=23.0.0",
"gevent-websocket>=0.10.1",
"docker>=7.0.0",
]

Expand Down
3 changes: 2 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Flask>=3.0.0
PyJWT>=2.8.0
gunicorn>=22.0.0
flask-sock>=0.3.0
flask-limiter>=3.0.0
gevent>=23.0.0
gevent-websocket>=0.10.1
docker>=7.0.0
cryptography>=42.0.0
36 changes: 34 additions & 2 deletions backend/src/simples_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from __future__ import annotations

import logging

from flask import Flask, jsonify
from flask_limiter import Limiter
from flask_limiter.errors import RateLimitExceeded as LimiterRateLimitExceeded
from flask_limiter.util import get_remote_address
from flask_sock import Sock

from .auth import AuthError
Expand All @@ -9,27 +14,54 @@
create_auth_blueprint,
create_compile_blueprint,
create_health_blueprint,
create_limits_blueprint,
register_run_ws,
)

import simple_websocket.ws as _sws
_original_choose = _sws.Server.choose_subprotocol
_sws.Server.choose_subprotocol = lambda self, req: (
req.subprotocols[0] if req.subprotocols else None
)


def create_app(settings: Settings | None = None) -> Flask:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
"""Flask application factory."""

app = Flask(__name__)

resolved = load_settings() if settings is None else settings
app.config["SETTINGS"] = resolved

limiter = Limiter(
get_remote_address,
app=app,
storage_uri="memory://",
default_limits=[],
strategy="fixed-window",
)

@app.errorhandler(AuthError)
def handle_auth_error(exc: AuthError):
return jsonify({"error": exc.code}), 401

@app.errorhandler(LimiterRateLimitExceeded)
def handle_rate_limit(exc: LimiterRateLimitExceeded):
return jsonify({
"error": "rate_limit_exceeded",
"detail": "máximo de execuções excedido",
}), 429

app.register_blueprint(create_auth_blueprint(resolved), url_prefix="/api/auth")
app.register_blueprint(create_compile_blueprint(resolved), url_prefix="/api")
app.register_blueprint(
create_compile_blueprint(resolved, limiter=limiter),
url_prefix="/api",
)
app.register_blueprint(create_health_blueprint(resolved), url_prefix="/api/health")
app.register_blueprint(create_limits_blueprint(resolved), url_prefix="/api")

sock = Sock(app)
register_run_ws(sock, resolved)
register_run_ws(sock, resolved, limiter=limiter)

return app
89 changes: 80 additions & 9 deletions backend/src/simples_backend/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from __future__ import annotations

import base64
import json
import logging
import urllib.request
from functools import wraps
from typing import Any, Callable, TypeVar, TypedDict, cast

import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from flask import g, request

logger = logging.getLogger(__name__)


class AuthError(Exception):
def __init__(self, code: str):
Expand All @@ -32,17 +39,81 @@ def extract_bearer_token(authorization_header: str | None) -> str:
return parts[1]


def verify_supabase_jwt(token: str, secret: str) -> Identity:
_jwks_keys: list[dict] | None = None


def _b64_decode(value: str) -> bytes:
padding = 4 - len(value) % 4
if padding != 4:
value += "=" * padding
return base64.urlsafe_b64decode(value)


def _get_jwks_keys(supabase_url: str) -> list[dict]:
global _jwks_keys
if _jwks_keys is None:
base = supabase_url.rstrip("/")
if "/rest/v1" in base:
base = base.split("/rest/v1")[0]
jwks_url = f"{base}/auth/v1/.well-known/jwks.json"
logger.info("fetching JWKS from %s", jwks_url)
resp = urllib.request.urlopen(jwks_url, timeout=10)
data = json.loads(resp.read())
_jwks_keys = data.get("keys", [])
logger.info("JWKS loaded: %d keys", len(_jwks_keys))
return _jwks_keys


def _build_ec_key(key_data: dict):
x_bytes = _b64_decode(key_data["x"])
y_bytes = _b64_decode(key_data["y"])
x_int = int.from_bytes(x_bytes, "big")
y_int = int.from_bytes(y_bytes, "big")
return ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1()).public_key()


def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Identity:
try:
claims = jwt.decode(
token,
secret,
algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
header = jwt.get_unverified_header(token)
alg = header.get("alg", "")
kid = header.get("kid", "")
logger.info("JWT alg=%s kid=%s", alg, kid)

claims = None

verify_opts = {"require": ["exp", "sub"], "verify_aud": False}

if alg == "ES256":
try:
keys = _get_jwks_keys(supabase_url)
match = next((k for k in keys if k.get("kid") == kid), None)
if match:
pubkey = _build_ec_key(match)
claims = jwt.decode(
token,
pubkey,
algorithms=["ES256"],
options=verify_opts,
)
logger.info("JWT verified with ES256+JWKS")
else:
logger.warning("no JWKS key found for kid=%s", kid)
except Exception as exc:
logger.error("ES256+JWKS failed: %s: %s", type(exc).__name__, exc)

if claims is None and alg == "HS256":
claims = jwt.decode(
token,
secret,
algorithms=["HS256"],
options=verify_opts,
)
logger.info("JWT verified with HS256")

except jwt.ExpiredSignatureError as exc:
raise AuthError("expired_token") from exc
except jwt.InvalidTokenError as exc:
logger.error("JWT invalid: %s: %s", type(exc).__name__, exc)
raise AuthError("invalid_token") from exc

sub = cast(str | None, claims.get("sub"))
Expand All @@ -61,12 +132,12 @@ def verify_supabase_jwt(token: str, secret: str) -> Identity:
F = TypeVar("F", bound=Callable[..., Any])


def verify_jwt(secret: str) -> Callable[[F], F]:
def verify_jwt(secret: str, supabase_url: str = "") -> Callable[[F], F]:
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any):
token = extract_bearer_token(request.headers.get("Authorization"))
g.identity = verify_supabase_jwt(token, secret)
g.identity = verify_supabase_jwt(token, secret, supabase_url)
return func(*args, **kwargs)

return cast(F, wrapper)
Expand Down
10 changes: 8 additions & 2 deletions backend/src/simples_backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ class Settings:
compile_timeout_s: int = 15
max_code_kb: int = 64
sandbox_image: str = "simples-runner:latest"
stop_timeout_s: int = 12
runs_per_minute: int = 30
runs_per_minute_ip: int = 120


_REQUIRED_ENV_VARS = ("SUPABASE_URL", "SUPABASE_JWT_SECRET")
_REQUIRED_ENV_VARS = ("SUPABASE_URL", "JWT_SECRET")


def _int_env(env: Mapping[str, str], key: str, default: int) -> int:
Expand All @@ -39,9 +42,12 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings:

return Settings(
supabase_url=env["SUPABASE_URL"],
supabase_jwt_secret=env["SUPABASE_JWT_SECRET"],
supabase_jwt_secret=env["JWT_SECRET"],
exec_timeout_s=_int_env(env, "EXEC_TIMEOUT_S", 10),
compile_timeout_s=_int_env(env, "COMPILE_TIMEOUT_S", 15),
max_code_kb=_int_env(env, "MAX_CODE_KB", 64),
sandbox_image=env.get("SANDBOX_IMAGE", "simples-runner:latest"),
stop_timeout_s=_int_env(env, "STOP_TIMEOUT_S", 12),
runs_per_minute=_int_env(env, "RUNS_PER_MINUTE", 30),
runs_per_minute_ip=_int_env(env, "RUNS_PER_MINUTE_IP", 120),
)
50 changes: 50 additions & 0 deletions backend/src/simples_backend/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import threading
import time


class RateLimitExceeded(Exception):
def __init__(self, key: str, max_requests: int, window_s: int):
self.key = key
self.max_requests = max_requests
self.window_s = window_s
super().__init__(f"rate limit exceeded: {max_requests} per {window_s}s")


class SlidingWindowRateLimiter:
def __init__(self, max_requests: int, window_s: int = 60):
self.max_requests = max_requests
self.window_s = window_s
self._lock = threading.Lock()
self._buckets: dict[str, list[float]] = {}

def is_allowed(self, key: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_s
with self._lock:
timestamps = self._buckets.get(key, [])
timestamps = [t for t in timestamps if t > cutoff]
if len(timestamps) >= self.max_requests:
self._buckets[key] = timestamps
return False
timestamps.append(now)
self._buckets[key] = timestamps
return True

def remaining(self, key: str) -> int:
now = time.monotonic()
cutoff = now - self.window_s
with self._lock:
timestamps = self._buckets.get(key, [])
timestamps = [t for t in timestamps if t > cutoff]
self._buckets[key] = timestamps
return max(0, self.max_requests - len(timestamps))

def reset(self, key: str) -> None:
with self._lock:
self._buckets.pop(key, None)

def check(self, key: str) -> None:
if not self.is_allowed(key):
raise RateLimitExceeded(key, self.max_requests, self.window_s)
2 changes: 2 additions & 0 deletions backend/src/simples_backend/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from .auth import create_auth_blueprint
from .compile import create_compile_blueprint
from .health import create_health_blueprint
from .limits import create_limits_blueprint
from .run_ws import register_run_ws

__all__ = [
"create_auth_blueprint",
"create_compile_blueprint",
"create_health_blueprint",
"create_limits_blueprint",
"register_run_ws",
]
17 changes: 15 additions & 2 deletions backend/src/simples_backend/routes/compile.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from flask import Blueprint, jsonify, request
from flask import Blueprint, g, jsonify, request
from flask_limiter import Limiter

from ..auth import verify_jwt
from ..config import Settings
Expand All @@ -9,11 +10,23 @@
MAX_CODE_SIZE = 1_000_000


def create_compile_blueprint(settings: Settings) -> Blueprint:
def _user_key() -> str:
identity = g.get("identity")
if identity:
return identity.get("user_id", "unknown")
return request.remote_addr or "unknown"


def create_compile_blueprint(
settings: Settings,
limiter: Limiter | None = None,
) -> Blueprint:
bp = Blueprint("compile", __name__)

@bp.post("/compile")
@verify_jwt(settings.supabase_jwt_secret)
@limiter.limit(lambda: f"{settings.runs_per_minute}/minute", key_func=_user_key)
@limiter.limit(lambda: f"{settings.runs_per_minute_ip}/minute", key_func=lambda: request.remote_addr or "unknown")
def compile_code():
if request.content_length and request.content_length > MAX_CODE_SIZE:
return jsonify({"error": "code_too_large"}), 413
Expand Down
21 changes: 21 additions & 0 deletions backend/src/simples_backend/routes/limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import annotations

from flask import Blueprint, jsonify

from ..config import Settings


def create_limits_blueprint(settings: Settings) -> Blueprint:
bp = Blueprint("limits", __name__)

@bp.get("/limits")
def limits():
return jsonify({
"exec_timeout_s": settings.exec_timeout_s,
"compile_timeout_s": settings.compile_timeout_s,
"max_code_kb": settings.max_code_kb,
"runs_per_minute": settings.runs_per_minute,
"runs_per_minute_ip": settings.runs_per_minute_ip,
})

return bp
Loading
Loading