-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 1: Auth + CORS + Rate Limiting #219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,152 @@ | ||||||||
| """Authentication and rate-limiting middleware for vibeDeploy gateway.""" | ||||||||
|
|
||||||||
| import hmac | ||||||||
| import logging | ||||||||
| import os | ||||||||
| import time | ||||||||
| from collections import defaultdict | ||||||||
|
|
||||||||
| from fastapi import HTTPException, Request, Security | ||||||||
| from fastapi.security import APIKeyHeader | ||||||||
|
|
||||||||
| logger = logging.getLogger(__name__) | ||||||||
|
|
||||||||
| _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) | ||||||||
|
|
||||||||
| _PUBLIC_PATHS: frozenset[str] = frozenset( | ||||||||
| { | ||||||||
| "/", | ||||||||
| "/health", | ||||||||
| "/cost-estimate", | ||||||||
| "/api/cost-estimate", | ||||||||
| "/models", | ||||||||
| "/api/models", | ||||||||
| } | ||||||||
| ) | ||||||||
|
|
||||||||
| _PUBLIC_PREFIXES: tuple[str, ...] = ("/test/",) | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||
|
|
||||||||
|
|
||||||||
| def _get_api_key() -> str: | ||||||||
| for key in ("VIBEDEPLOY_API_KEY", "VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN"): | ||||||||
| value = os.getenv(key, "").strip() | ||||||||
| if value: | ||||||||
| return value | ||||||||
| return "" | ||||||||
|
Comment on lines
+29
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
PR ๋ชฉํ๋๋ก๋ผ๋ฉด ์์ ์์ def _get_api_key() -> str:
- for key in ("VIBEDEPLOY_API_KEY", "VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN"):
- value = os.getenv(key, "").strip()
- if value:
- return value
- return ""
+ return os.getenv("VIBEDEPLOY_API_KEY", "").strip()๐ค Prompt for AI Agents |
||||||||
|
|
||||||||
|
|
||||||||
| def _is_public_path(path: str) -> bool: | ||||||||
| if path in _PUBLIC_PATHS: | ||||||||
| return True | ||||||||
| return any(path.startswith(prefix) for prefix in _PUBLIC_PREFIXES) | ||||||||
|
|
||||||||
|
|
||||||||
| async def verify_api_key( | ||||||||
| request: Request, | ||||||||
| api_key: str | None = Security(_api_key_header), | ||||||||
| ) -> str | None: | ||||||||
| path = request.url.path | ||||||||
|
|
||||||||
| if _is_public_path(path): | ||||||||
| return None | ||||||||
|
|
||||||||
| expected = _get_api_key() | ||||||||
|
|
||||||||
| # Auth disabled when no key is configured (dev mode) | ||||||||
| if not expected: | ||||||||
| return None | ||||||||
|
|
||||||||
| # SSE endpoints: fall back to query param (EventSource can't send headers) | ||||||||
| if not api_key and "/events" in path: | ||||||||
| api_key = request.query_params.get("api_key") | ||||||||
|
|
||||||||
| if not api_key: | ||||||||
| raise HTTPException( | ||||||||
| status_code=401, | ||||||||
| detail="missing_api_key", | ||||||||
| headers={"WWW-Authenticate": "ApiKey"}, | ||||||||
| ) | ||||||||
|
|
||||||||
| if not hmac.compare_digest(api_key, expected): | ||||||||
| logger.warning("Invalid API key from %s for %s", request.client.host if request.client else "unknown", path) | ||||||||
| raise HTTPException(status_code=403, detail="invalid_api_key") | ||||||||
|
|
||||||||
| return api_key | ||||||||
|
|
||||||||
|
|
||||||||
| class _RateLimitBucket: | ||||||||
| __slots__ = ("_requests",) | ||||||||
|
|
||||||||
| def __init__(self) -> None: | ||||||||
| self._requests: list[float] = [] | ||||||||
|
|
||||||||
| def hit(self, now: float, window_seconds: int, max_requests: int) -> bool: | ||||||||
| cutoff = now - window_seconds | ||||||||
| self._requests = [t for t in self._requests if t > cutoff] | ||||||||
| if len(self._requests) >= max_requests: | ||||||||
| return False | ||||||||
| self._requests.append(now) | ||||||||
| return True | ||||||||
|
|
||||||||
|
|
||||||||
| _rate_buckets: defaultdict[str, _RateLimitBucket] = defaultdict(_RateLimitBucket) | ||||||||
|
||||||||
|
|
||||||||
| _RATE_LIMITS: dict[str, tuple[int, int]] = { | ||||||||
| "write": (10, 60), | ||||||||
| "read": (120, 60), | ||||||||
| "sse": (20, 60), | ||||||||
| } | ||||||||
|
|
||||||||
| _WRITE_PATHS: frozenset[str] = frozenset( | ||||||||
| { | ||||||||
| "/run", | ||||||||
| "/api/run", | ||||||||
| "/resume", | ||||||||
| "/api/resume", | ||||||||
| "/brainstorm", | ||||||||
| "/api/brainstorm", | ||||||||
| "/zero-prompt/start", | ||||||||
| "/api/zero-prompt/start", | ||||||||
| "/zero-prompt/reset", | ||||||||
| "/api/zero-prompt/reset", | ||||||||
| } | ||||||||
| ) | ||||||||
|
|
||||||||
| _SSE_FRAGMENTS: tuple[str, ...] = ("/events", "/build/") | ||||||||
|
|
||||||||
|
|
||||||||
| def _classify_rate_tier(path: str, method: str) -> str: | ||||||||
| if path in _WRITE_PATHS: | ||||||||
| return "write" | ||||||||
| for fragment in _SSE_FRAGMENTS: | ||||||||
| if fragment in path: | ||||||||
| return "sse" | ||||||||
| if method == "POST" and "/actions" in path: | ||||||||
| return "write" | ||||||||
| return "read" | ||||||||
|
|
||||||||
|
|
||||||||
| async def rate_limit_check(request: Request) -> None: | ||||||||
| path = request.url.path | ||||||||
|
|
||||||||
| if _is_public_path(path): | ||||||||
| return | ||||||||
|
|
||||||||
| client_ip = request.client.host if request.client else "unknown" | ||||||||
|
||||||||
| client_ip = request.client.host if request.client else "unknown" | |
| forwarded = request.headers.get("x-forwarded-for") | |
| client_ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown") |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
๐งฉ Analysis chain
๐ Script executed:
# First, let's look at the auth.py file to understand the context
cat -n agent/auth.py | head -160 | tail -20Repository: Two-Weeks-Team/vibeDeploy
Length of output: 765
๐ Script executed:
# Check if there's a .do/app.yaml file
find . -name "app.yaml" -o -name ".do" -type dRepository: Two-Weeks-Team/vibeDeploy
Length of output: 89
๐ Script executed:
# Search for any Uvicorn configuration in the codebase
rg -i "uvicorn|proxy|trusted" --type py -A 3Repository: Two-Weeks-Team/vibeDeploy
Length of output: 12541
๐ Script executed:
# Search for any usage of do-connecting-ip or similar headers
rg -i "do-connecting-ip|forwarded-allow" --type py -A 2Repository: Two-Weeks-Team/vibeDeploy
Length of output: 51
๐ Script executed:
# Check pyproject.toml for Uvicorn or proxy-related settings
cat pyproject.toml | grep -i -A 5 -B 5 "uvicorn\|proxy\|trusted"Repository: Two-Weeks-Team/vibeDeploy
Length of output: 115
๐ Script executed:
# Check the .do/app.yaml deployment configuration
cat ./.do/app.yamlRepository: Two-Weeks-Team/vibeDeploy
Length of output: 3333
๐ Script executed:
# Check the Uvicorn startup code in run_server.py
cat -n agent/run_server.pyRepository: Two-Weeks-Team/vibeDeploy
Length of output: 962
๐ Script executed:
# Check the Uvicorn startup code in server.py
cat -n agent/server.py | head -50Repository: Two-Weeks-Team/vibeDeploy
Length of output: 2007
๐ Script executed:
# Search for any middleware that might handle headers or proxy settings
rg -i "middleware|header|forwarded|proxy" agent/ --type py -B 2 -A 2Repository: Two-Weeks-Team/vibeDeploy
Length of output: 43174
๐ Script executed:
# Search for any request.headers or custom header handling
rg "request\.headers|do-connecting|X-Forwarded" agent/ --type py -A 3Repository: Two-Weeks-Team/vibeDeploy
Length of output: 51
๐ Web query:
DigitalOcean App Platform client IP header do-connecting-ip documentation
๐ก Result:
DigitalOcean App Platform sets a request header named do-connecting-ip that contains the original client IP address. By contrast, x-forwarded-for on App Platform is used for the DigitalOcean ingress server IP that forwarded the request to your app. [1]
Sources:
[1] DigitalOcean Docs โ โWhere can I find the client IP address of a request connecting to my app?โ
DigitalOcean App Platform ํ๊ฒฝ์์ ์์ฒญ ํด๋ผ์ด์ธํธ IP ์๋ ๊ฐ์ง๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์ฒ๋ฆฌํ์ง ์์ผ๋ฉด ์ค์ ์ฌ์ฉ์๋ณ์ด ์๋ ingress IP๋ณ๋ก rate limit์ด ์ ์ฉ๋ฉ๋๋ค.
ํ์ฌ ์ฝ๋๋ request.client.host๋ฅผ ์ฌ์ฉํ๊ณ ์๋๋ฐ, Uvicorn์ ๊ธฐ๋ณธ ์ค์ ์์๋ trusted proxy๋ฅผ ๋ช
์์ ์ผ๋ก ๊ตฌ์ฑํ์ง ์์ ์ํ์
๋๋ค. DigitalOcean App Platform์์๋ ์ค์ ํด๋ผ์ด์ธํธ IP๋ฅผ do-connecting-ip ํค๋์ ๋ด์ ์ ๋ฌํ๋ฉฐ, X-Forwarded-For๋ ingress ์๋ฒ IP๋ฅผ ํฌํจํฉ๋๋ค. ๋ฐ๋ผ์ ํ์ฌ ์ค์ ์์๋ request.client.host๊ฐ DigitalOcean์ ๊ณต์ ingress IP๋ฅผ ๋ฐํํ๊ฒ ๋๊ณ , ์ด๋ก ์ธํด ๋์ผํ ingress ๋ค์ ์๋ ๋ชจ๋ ์ฌ์ฉ์๊ฐ ํ๋์ rate limit bucket์ ๊ณต์ ํ๊ฒ ๋ฉ๋๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก ํ ์ฌ์ฉ์์ ์ ์ ์์ฒญ์ด ๋ค๋ฅธ ์ฌ์ฉ์๋ค์ ํธ๋ํฝ์ผ๋ก ์ธํด ์๋์น ์์ 429 ์ค๋ฅ๋ฅผ ๋ฐ์ ์ ์์ต๋๋ค.
DigitalOcean App Platform ์ ์ฉ ๋ฐฐํฌ์ธ ๋งํผ do-connecting-ip ํค๋๋ฅผ ์ฐ์ ์ ์ผ๋ก ์ฌ์ฉํ๊ฑฐ๋, Uvicorn์ trusted proxy ์ค์ ์ ๋ช
์์ ์ผ๋ก ๊ตฌ์ฑํ์ฌ ์ค์ ํด๋ผ์ด์ธํธ IP๋ฅผ ์ ํํ ๊ฐ์งํ๋ ๊ฒ์ด ํ์์
๋๋ค.
๐ค Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@agent/auth.py` around lines 150 - 154, The rate-limiting currently derives
client_ip from request.client.host (see client_ip and request.client.host) which
on DigitalOcean returns the ingress IP; update the logic in the handler that
builds bucket_key to first check the do-connecting-ip header (and then
X-Forwarded-For fallback), parse/normalize the header to extract the left-most
real client IP, and only fall back to request.client.host if neither header is
present; keep using _classify_rate_tier and _RATE_LIMITS but ensure bucket_key
uses the resolved client IP so per-user buckets are correct.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -27,7 +27,7 @@ | |||||
| import httpx | ||||||
| import uvicorn | ||||||
| from dotenv import load_dotenv | ||||||
| from fastapi import FastAPI, Header, HTTPException, Request | ||||||
| from fastapi import Depends, FastAPI, Header, HTTPException, Request | ||||||
| from fastapi.middleware.cors import CORSMiddleware | ||||||
| from pydantic import BaseModel | ||||||
| from starlette.responses import JSONResponse, StreamingResponse | ||||||
|
|
@@ -346,7 +346,7 @@ def _meeting_store_payload(meeting: dict) -> dict: | |||||
|
|
||||||
|
|
||||||
| def _ops_token() -> str: | ||||||
| for key in ("VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN", "DIGITALOCEAN_INFERENCE_KEY"): | ||||||
| for key in ("VIBEDEPLOY_OPS_TOKEN", "DASHBOARD_ADMIN_TOKEN"): | ||||||
| value = os.getenv(key, "").strip() | ||||||
| if value: | ||||||
| return value | ||||||
|
|
@@ -610,13 +610,30 @@ async def lifespan(app: FastAPI): | |||||
| _store = None | ||||||
|
|
||||||
|
|
||||||
| app = FastAPI(title="vibeDeploy Agent (local)", lifespan=lifespan) | ||||||
| from .auth import rate_limit_check, verify_api_key | ||||||
|
|
||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| app = FastAPI( | ||||||
| title="vibeDeploy Agent (local)", | ||||||
| lifespan=lifespan, | ||||||
| dependencies=[Depends(verify_api_key), Depends(rate_limit_check)], | ||||||
| ) | ||||||
|
|
||||||
| _ALLOWED_ORIGINS = [ | ||||||
| origin.strip() | ||||||
| for origin in os.getenv( | ||||||
| "VIBEDEPLOY_CORS_ORIGINS", | ||||||
| "https://vibedeploy-7tgzk.ondigitalocean.app,http://localhost:3000,http://localhost:9001", | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default value for
Suggested change
|
||||||
| ).split(",") | ||||||
| if origin.strip() | ||||||
| ] | ||||||
|
|
||||||
| app.add_middleware( | ||||||
| CORSMiddleware, | ||||||
| allow_origins=["*"], | ||||||
| allow_methods=["*"], | ||||||
| allow_headers=["*"], | ||||||
| allow_origins=_ALLOWED_ORIGINS, | ||||||
| allow_methods=["GET", "POST", "PUT", "OPTIONS"], | ||||||
| allow_headers=["Content-Type", "X-API-Key", "X-Vibedeploy-Ops-Token"], | ||||||
| allow_credentials=False, | ||||||
| max_age=600, | ||||||
| ) | ||||||
|
|
||||||
| _NODE_EVENTS = NODE_EVENTS | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.