Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e12f9b27f |
File diff suppressed because one or more lines are too long
@@ -128,9 +128,6 @@ RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
|
|||||||
RATE_WINDOW = 60
|
RATE_WINDOW = 60
|
||||||
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
|
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
|
||||||
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
|
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
|
||||||
LOGIN_EMAIL_RATE_LIMIT = int(os.environ.get("DEVPLACE_LOGIN_EMAIL_RATE_LIMIT", "10"))
|
|
||||||
|
|
||||||
_email_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
|
||||||
|
|
||||||
HOT_SETTINGS_TTL = 2.0
|
HOT_SETTINGS_TTL = 2.0
|
||||||
_hot_settings_value: dict = {}
|
_hot_settings_value: dict = {}
|
||||||
@@ -170,20 +167,6 @@ def _worker_rate_limit(limit: int) -> int:
|
|||||||
return max(1, -(-limit // WEB_WORKERS))
|
return max(1, -(-limit // WEB_WORKERS))
|
||||||
|
|
||||||
|
|
||||||
def check_email_rate_limit(email: str) -> bool:
|
|
||||||
now = time.time()
|
|
||||||
window_start = now - RATE_WINDOW
|
|
||||||
limit = LOGIN_EMAIL_RATE_LIMIT
|
|
||||||
timestamps = [
|
|
||||||
t for t in _email_rate_limit_store.get(email, []) if t > window_start
|
|
||||||
]
|
|
||||||
if len(timestamps) >= limit:
|
|
||||||
return False
|
|
||||||
timestamps.append(now)
|
|
||||||
_email_rate_limit_store[email] = timestamps
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
_service_lock_handle = None
|
_service_lock_handle = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from devplacepy.responses import respond, action_result, wants_json, json_error
|
|||||||
from devplacepy.schemas import AuthPageOut
|
from devplacepy.schemas import AuthPageOut
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.dependencies import json_or_form
|
from devplacepy.dependencies import json_or_form
|
||||||
from devplacepy.main import check_email_rate_limit
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -73,22 +72,6 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
|
|||||||
metadata={"email": email},
|
metadata={"email": email},
|
||||||
summary=f"failed login attempt for {email}",
|
summary=f"failed login attempt for {email}",
|
||||||
)
|
)
|
||||||
if not check_email_rate_limit(email):
|
|
||||||
audit.record(
|
|
||||||
request,
|
|
||||||
"security.rate_limit.email_block",
|
|
||||||
user=None,
|
|
||||||
actor_kind="guest",
|
|
||||||
result="denied",
|
|
||||||
metadata={"email": email},
|
|
||||||
summary=f"email rate limit reached for {email}",
|
|
||||||
)
|
|
||||||
if wants_json(request):
|
|
||||||
return json_error(429, "Too many login attempts for this account")
|
|
||||||
return HTMLResponse(
|
|
||||||
"Too many login attempts for this account",
|
|
||||||
status_code=429,
|
|
||||||
)
|
|
||||||
if wants_json(request):
|
if wants_json(request):
|
||||||
return json_error(401, "; ".join(errors), errors=errors)
|
return json_error(401, "; ".join(errors), errors=errors)
|
||||||
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
|
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export class CommentManager {
|
|||||||
this.initCommentReply();
|
this.initCommentReply();
|
||||||
this.initCommentEdit();
|
this.initCommentEdit();
|
||||||
this.initCommentDelete();
|
this.initCommentDelete();
|
||||||
|
document.addEventListener("paste", (event) => {
|
||||||
|
const textarea = event.target.closest("form textarea[name='body'], form textarea[name='content']");
|
||||||
|
if (!textarea) return;
|
||||||
|
this._onPaste(event, textarea);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initCommentDelete() {
|
initCommentDelete() {
|
||||||
@@ -208,4 +213,41 @@ export class CommentManager {
|
|||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onPaste(event, textarea) {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
const namedFile = new File([file], file.name || "clipboard.png", { type: file.type });
|
||||||
|
this._uploadImage(namedFile, textarea);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uploadImage(file, textarea) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, file.name);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/uploads/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Requested-With": "fetch",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Upload failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
window.TextInput.insertAtCursor(textarea, ``);
|
||||||
|
} catch (err) {
|
||||||
|
Http.notifyError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,48 @@ export class IssueReporter {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.submit(form);
|
this.submit(form);
|
||||||
});
|
});
|
||||||
|
document.addEventListener("paste", (event) => {
|
||||||
|
const textarea = event.target.closest("form[data-issue-create] [name='description']");
|
||||||
|
if (!textarea) return;
|
||||||
|
this._onPaste(event, textarea);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onPaste(event, textarea) {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
const namedFile = new File([file], file.name || "clipboard.png", { type: file.type });
|
||||||
|
this._uploadImage(namedFile, textarea);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _uploadImage(file, textarea) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, file.name);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/uploads/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Requested-With": "fetch",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Upload failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
window.TextInput.insertAtCursor(textarea, ``);
|
||||||
|
} catch (err) {
|
||||||
|
Http.notifyError(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit(form) {
|
async submit(form) {
|
||||||
|
|||||||
@@ -181,27 +181,3 @@ def test_rate_limit_block_recorded(monkeypatch):
|
|||||||
event_key="security.rate_limit.block", result="denied"
|
event_key="security.rate_limit.block", result="denied"
|
||||||
)
|
)
|
||||||
assert event is not None
|
assert event is not None
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_email_block_recorded(monkeypatch):
|
|
||||||
import devplacepy.main as m
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
monkeypatch.setattr(m, "LOGIN_EMAIL_RATE_LIMIT", 2)
|
|
||||||
m._email_rate_limit_store.clear()
|
|
||||||
client = TestClient(m.app)
|
|
||||||
email = "rate-limited@test.dev"
|
|
||||||
codes = [
|
|
||||||
client.post(
|
|
||||||
"/auth/login",
|
|
||||||
data={"email": email, "password": "wrong"},
|
|
||||||
).status_code
|
|
||||||
for _ in range(3)
|
|
||||||
]
|
|
||||||
first_two = codes[:2]
|
|
||||||
assert all(c == 401 for c in first_two), first_two
|
|
||||||
assert codes[2] == 429, codes
|
|
||||||
event = get_table("audit_log").find_one(
|
|
||||||
event_key="security.rate_limit.email_block", result="denied"
|
|
||||||
)
|
|
||||||
assert event is not None
|
|
||||||
|
|||||||
@@ -240,3 +240,17 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
|
|||||||
bob = _session_uploads()
|
bob = _session_uploads()
|
||||||
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
|
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
|
||||||
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200
|
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_paste_image_upload_returns_url(app_server):
|
||||||
|
s = _session_uploads()
|
||||||
|
r = s.post(
|
||||||
|
f"{BASE_URL}/uploads/upload",
|
||||||
|
files={"file": ("clipboard.png", _png_bytes_uploads(), "image/png")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
data = r.json()
|
||||||
|
assert "url" in data
|
||||||
|
assert data["url"].startswith("/static/uploads/attachments/")
|
||||||
|
assert data["uid"]
|
||||||
|
assert data["is_image"] is True
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ os.environ["DEVPLACE_DATA_DIR"] = str(_TEST_DATA_DIR)
|
|||||||
os.environ["SECRET_KEY"] = "test-secret-key"
|
os.environ["SECRET_KEY"] = "test-secret-key"
|
||||||
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
|
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
|
||||||
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
|
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
|
||||||
os.environ["DEVPLACE_LOGIN_EMAIL_RATE_LIMIT"] = "1000000"
|
|
||||||
# Pin a single web worker so the per-worker rate-limit divisor is 1 regardless of
|
# Pin a single web worker so the per-worker rate-limit divisor is 1 regardless of
|
||||||
# any DEVPLACE_WEB_WORKERS the host (or a server .env) exports.
|
# any DEVPLACE_WEB_WORKERS the host (or a server .env) exports.
|
||||||
os.environ["DEVPLACE_WEB_WORKERS"] = "1"
|
os.environ["DEVPLACE_WEB_WORKERS"] = "1"
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.11.2
|
||||||
|
executable = /usr/bin/python3.11
|
||||||
|
command = /usr/bin/python3 -m venv /workspace/repo/venv
|
||||||
Reference in New Issue
Block a user