-
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
base: main
Are you sure you want to change the base?
Changes from all commits
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,172 @@ | ||||||||
| """Authentication and rate-limiting middleware for vibeDeploy gateway.""" | ||||||||
|
|
||||||||
| import hmac | ||||||||
| import logging | ||||||||
| import os | ||||||||
| import time | ||||||||
| from collections import defaultdict | ||||||||
|
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. ์ฌ์ฉํ์ง ์๋ ํ์ฌ CI๊ฐ As per coding guidelines, ๐งฐ Tools๐ช GitHub Actions: CI[error] 7-7: ruff check: F401 ๐ค Prompt for AI Agents |
||||||||
|
|
||||||||
| 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
+30
to
+35
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 | ||||||||
|
|
||||||||
| def is_empty(self) -> bool: | ||||||||
| return len(self._requests) == 0 | ||||||||
|
|
||||||||
|
|
||||||||
| _rate_buckets: dict[str, _RateLimitBucket] = {} | ||||||||
| _BUCKET_CLEANUP_INTERVAL = 300 # seconds | ||||||||
| _last_bucket_cleanup = 0.0 | ||||||||
|
|
||||||||
| _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" | ||||||||
|
|
||||||||
|
|
||||||||
| def _cleanup_stale_buckets(now: float) -> None: | ||||||||
| global _last_bucket_cleanup | ||||||||
| if now - _last_bucket_cleanup < _BUCKET_CLEANUP_INTERVAL: | ||||||||
| return | ||||||||
| _last_bucket_cleanup = now | ||||||||
| stale = [k for k, b in _rate_buckets.items() if b.is_empty()] | ||||||||
| for k in stale: | ||||||||
| _rate_buckets.pop(k, None) | ||||||||
|
Comment on lines
+134
to
+141
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. stale bucket ์ ๋ฆฌ๊ฐ ์ค์ ๋ก ๋์ํ์ง ์์ต๋๋ค.
๐ค Prompt for AI Agents |
||||||||
|
|
||||||||
|
|
||||||||
| 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" | ||||||||
|
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. Using
Suggested change
|
||||||||
| method = request.method | ||||||||
| tier = _classify_rate_tier(path, method) | ||||||||
| max_requests, window_seconds = _RATE_LIMITS[tier] | ||||||||
| bucket_key = f"{client_ip}:{tier}" | ||||||||
|
Comment on lines
+150
to
+154
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. ๐งฉ 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:
๐ก Result: DigitalOcean App Platform sets a request header named Sources: DigitalOcean App Platform ํ๊ฒฝ์์ ์์ฒญ ํด๋ผ์ด์ธํธ IP ์๋ ๊ฐ์ง๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์ฒ๋ฆฌํ์ง ์์ผ๋ฉด ์ค์ ์ฌ์ฉ์๋ณ์ด ์๋ ingress IP๋ณ๋ก rate limit์ด ์ ์ฉ๋ฉ๋๋ค. ํ์ฌ ์ฝ๋๋ DigitalOcean App Platform ์ ์ฉ ๋ฐฐํฌ์ธ ๋งํผ ๐ค Prompt for AI Agents |
||||||||
| now = time.monotonic() | ||||||||
|
|
||||||||
| _cleanup_stale_buckets(now) | ||||||||
|
|
||||||||
| if bucket_key not in _rate_buckets: | ||||||||
| _rate_buckets[bucket_key] = _RateLimitBucket() | ||||||||
|
|
||||||||
| if not _rate_buckets[bucket_key].hit(now, window_seconds, max_requests): | ||||||||
| logger.warning("Rate limit exceeded: %s %s from %s (tier=%s)", method, path, client_ip, tier) | ||||||||
| raise HTTPException( | ||||||||
| status_code=429, | ||||||||
| detail="rate_limit_exceeded", | ||||||||
| headers={ | ||||||||
| "Retry-After": str(window_seconds), | ||||||||
| "X-RateLimit-Limit": str(max_requests), | ||||||||
| "X-RateLimit-Window": str(window_seconds), | ||||||||
| }, | ||||||||
| ) | ||||||||
| 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 | ||||||||||||||||||
|
|
||||||||||||||||||
|
Comment on lines
+613
to
+614
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. E402 ๋ฆฐํธ ์ค๋ฅ: ๋ชจ๋ ๋ ๋ฒจ import๊ฐ ํ์ผ ์๋จ์ ์์นํ์ง ์์. ํ์ดํ๋ผ์ธ ์คํจ ๋ก๊ทธ์ ๋ฐ๋ฅด๋ฉด, line 613์ import ๋ฌธ์ด ํ์ผ ์๋จ์ด ์๋ ํจ์ ์ ์ ์ดํ์ ์์นํ์ฌ ๐ง ์์ ์ ์Line 30 ๊ทผ์ฒ์ ๋ค๋ฅธ import ๋ฌธ๋ค ์ฌ์ด์ ์ถ๊ฐ: from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
+from .auth import rate_limit_check, verify_api_key
from pydantic import BaseModel๊ทธ๋ฆฌ๊ณ line 613-614๋ฅผ ์ญ์ : -from .auth import rate_limit_check, verify_api_key
-
app = FastAPI(๐ Committable suggestion
Suggested change
Suggested change
๐งฐ Tools๐ช GitHub Actions: CI[error] 613-613: ruff check failed: E402 Module level import not at top of file. Import shown at server.py:613. ๐ค Prompt for AI Agents |
||||||||||||||||||
| 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 | ||||||||||||||||||
|
|
||||||||||||||||||
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:
git ls-files | head -20Repository: Two-Weeks-Team/vibeDeploy
Length of output: 471
๐ Script executed:
fd "app.yaml" --type fRepository: Two-Weeks-Team/vibeDeploy
Length of output: 51
๐ Script executed:
fd "fetch-with-auth" --type fRepository: Two-Weeks-Team/vibeDeploy
Length of output: 145
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 74
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 4257
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 1361
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 3924
๐ Script executed:
rg "fetch-with-auth" --type ts --type tsx -A 2 -B 2Repository: Two-Weeks-Team/vibeDeploy
Length of output: 96
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 96
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 301
๐ Script executed:
rg "from.*fetch-with-auth|import.*fetch-with-auth" --type tsRepository: Two-Weeks-Team/vibeDeploy
Length of output: 1743
๐ Script executed:
rg "import.*authHeaders|import.*authenticatedFetch" --type tsRepository: Two-Weeks-Team/vibeDeploy
Length of output: 422
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 237
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 323
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 11593
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 27261
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 912
๐ Script executed:
Repository: Two-Weeks-Team/vibeDeploy
Length of output: 4379
ํด๋ผ์ด์ธํธ ํ ์์ ์ธ์ฆ๋์ง ์์ API ํธ์ถ์ด ๋ฐ์ํฉ๋๋ค.
web/src/hooks/use-pipeline-monitor.ts์web/src/hooks/use-zero-prompt.ts๋"use client"๋งํฌ๋ฅผ ๊ฐ์ง ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์ธ๋ฐ, ๋ ํ์ผ ๋ชจ๋fetch-with-auth.ts์์authenticatedFetch()์appendApiKey()๋ฅผ ์ํฌํธํ์ฌ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ํ์ง๋ง.do/app.yaml๋ผ์ธ 129-132์VIBEDEPLOY_API_KEY๋NEXT_PUBLIC_์ ๋์ฌ ์์ดRUN_TIMESECRET์ผ๋ก๋ง ์ค์ ๋์ด ์์ผ๋ฏ๋ก, ๋ธ๋ผ์ฐ์ ์ฝ๋์์process.env.VIBEDEPLOY_API_KEY๋ ํญ์ undefined๊ฐ ๋์ดauthHeaders()๊ฐ ๋น ํค๋๋ฅผ,appendApiKey()๊ฐ ์์ ๋์ง ์์ URL์ ๋ฐํํฉ๋๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก ๋๋ถ๋ถ์ ํด๋ผ์ด์ธํธ ์์ฒญ์ด 401๋ก ์คํจํฉ๋๋ค.์ฌ๋ฐ๋ฅธ ํด๊ฒฐ์ฑ ์ ํด๋ผ์ด์ธํธ ์ธก ํ ์ ์์ ํ์ฌ ์ธ์ฆ์ ์ํด Next.js ์๋ฒ ๊ฒฝ์ ํ๋ก์๋ API ๋ผ์ฐํธ ํธ๋ค๋ฌ๋ฅผ ๊ฑฐ์ณ์ผ ํฉ๋๋ค. ์๋ฒ ์ปดํฌ๋ํธ(์:
zero-prompt/page.tsx์getInitialSession())๋ ์ด๋ฏธ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋์ด ์์ต๋๋ค.๐ค Prompt for AI Agents