Skip to content

Commit 1813528

Browse files
authored
replace /proxy route with something more secure (#10)
* replace /proxy route with something more secure * improving tests * completely remove use of urls.json file Signed-off-by: Luiz Oliveira <ziuloliveira@gmail.com>
1 parent be78232 commit 1813528

File tree

4 files changed

+202
-189
lines changed

4 files changed

+202
-189
lines changed

controller.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ def service_event(event, memo: kopf.Memo, logger, **kwargs):
3939

4040

4141
if event['type'] == 'DELETED':
42-
if f"{namespace}/{application_name}" in memo.apps:
43-
del memo.apps[f"{namespace}/{application_name}"]
42+
if f"{namespace}.{application_name}" in memo.apps:
43+
del memo.apps[f"{namespace}.{application_name}"]
4444
else:
45-
memo.apps.update({f"{namespace}/{application_name}": {
45+
memo.apps.update({f"{namespace}.{application_name}": {
4646
'url': urlunparse(application_url),
4747
'name': application_name,
4848
'header': annotations.get(name_header, ""),
@@ -54,4 +54,7 @@ def service_event(event, memo: kopf.Memo, logger, **kwargs):
5454
with file_lock:
5555
with open('static/openapi/urls.json', 'w') as file:
5656
json.dump(urls, file, indent=2)
57-
logger.info("URLs written to file.")
57+
logger.info("URLs written to file.")
58+
with open('static/openapi/services.json', 'w') as file:
59+
json.dump(memo.apps, file, indent=2)
60+
logger.info("Services written to file.")

server.py

Lines changed: 67 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import os
77
import requests
88
import logging
9-
from urllib.parse import quote, unquote
109
from starlette.responses import RedirectResponse
1110
from authlib.integrations.starlette_client import OAuth
1211
from starlette.config import Config
@@ -64,29 +63,29 @@ def require_login(request: Request):
6463
request.session['user'] = "anonymous"
6564
return request.session['user']
6665

67-
68-
@app.get("/proxy", include_in_schema=False)
69-
async def proxy(url: str, headers: str = None, user=Depends(require_login)):
70-
"""
71-
Proxy endpoint to fetch the OpenAPI document from a given URL (JSON or YAML).
72-
"""
73-
try:
74-
if headers:
75-
resp = requests.get(url, headers=json.loads(unquote(headers)), timeout=int(os.environ.get("PROXY_TIMEOUT", 10)))
76-
else:
77-
resp = requests.get(url, timeout=int(os.environ.get("PROXY_TIMEOUT", 10)))
78-
content_type = resp.headers.get("content-type", "")
79-
# Se for JSON, repasse como application/json
80-
if "json" in content_type:
81-
return Response(content=resp.content, media_type="application/json")
82-
# Se for YAML, repasse como text/yaml
83-
elif "yaml" in content_type or "yml" in content_type:
84-
return Response(content=resp.content, media_type="text/yaml")
85-
else:
86-
raise HTTPException(status_code=400, detail="Unsupported content type")
87-
except requests.RequestException as e:
88-
logger.error(f"Error fetching OpenAPI document: {e}")
89-
raise HTTPException(status_code=500, detail={"error": "Failed to fetch OpenAPI document", "details": str(e)})
66+
@app.get("/services/{name}", response_class=HTMLResponse, include_in_schema=False)
67+
async def services(request: Request, name: str, user=Depends(require_login)):
68+
with open('static/openapi/services.json', 'r') as f:
69+
services = json.load(f)
70+
logger.info(f"Loaded {len(services)} services.")
71+
if name not in services:
72+
logger.error(f"Service {name} not found.")
73+
raise HTTPException(status_code=404, detail="Service not found")
74+
service = services[name]
75+
if not service:
76+
logger.error(f"Service {name} not found.")
77+
raise HTTPException(status_code=404, detail="Service not found")
78+
resp = requests.get(service['url'], timeout=int(os.environ.get("PROXY_TIMEOUT", 10)), headers=parse_headers(service.get("header")))
79+
content_type = resp.headers.get("content-type", "")
80+
# Se for JSON, repasse como application/json
81+
if "json" in content_type:
82+
return Response(content=resp.content, media_type="application/json")
83+
# Se for YAML, repasse como text/yaml
84+
elif "yaml" in content_type or "yml" in content_type:
85+
return Response(content=resp.content, media_type="text/yaml")
86+
else:
87+
logger.error(f"Unsupported content type: {content_type}")
88+
raise HTTPException(status_code=400, detail="Unsupported content type")
9089

9190
def parse_headers(header_string: str) -> dict:
9291
headers = {}
@@ -100,81 +99,59 @@ def parse_headers(header_string: str) -> dict:
10099
return headers
101100

102101

103-
def apply_proxy_to_openapi(openapi_url: str, header: str = None) -> str:
104-
"""
105-
Apply the proxy to the OpenAPI URL.
106-
"""
107-
if openapi_url.startswith("http"):
108-
new_url = f"/proxy?url={openapi_url}"
109-
if header:
110-
header = quote(json.dumps(header))
111-
new_url += f"&headers={header}"
112-
return new_url
113-
return openapi_url
114-
115-
116102
@app.get("/", response_class=HTMLResponse)
117-
async def docs(request: Request, template:str=None, user=Depends(require_login)):
118-
"""
119-
Main documentation page.
120-
"""
103+
async def index(request: Request, template:str='swagger-ui', user=Depends(require_login)):
104+
if template.lower() not in ["redoc", "swagger-ui"]:
105+
raise HTTPException(status_code=400, detail="Invalid template. Use 'redoc' or 'swagger-ui'.")
106+
121107
try:
122-
with open('static/openapi/urls.json', 'r') as f:
123-
swaggers = json.load(f)
124-
logger.info(f"Loaded {len(swaggers)} URLs.")
125-
except FileNotFoundError:
126-
logger.error("File not found: static/openapi/urls.json")
127-
request.session['error'] = "File not found: static/openapi/urls.json"
128-
swaggers = [
129-
{
130-
"url": "/openapi.json",
131-
"name": "Swagger Aggregator",
132-
"header": "",
133-
}
134-
]
135-
except json.JSONDecodeError:
136-
logger.error("Error decoding JSON from static/openapi/urls.json")
137-
request.session['error'] = "Error decoding JSON from static/openapi/urls.json"
138-
swaggers = [
139-
{
140-
"url": "/openapi.json",
141-
"name": "Swagger Aggregator",
142-
"header": "",
143-
}
144-
]
145-
146-
for swagger in swaggers:
147-
swagger["url"] = apply_proxy_to_openapi(swagger.get("url"), parse_headers(swagger.get("header")))
148-
149-
if template and template.lower() in ["redoc", "swagger-ui"]:
150-
return templates.TemplateResponse(
151-
f"{template.lower()}.html",
152-
{
153-
"request": request,
154-
"urls": swaggers,
155-
"title": os.environ.get("TITLE", "API Documentation"),
156-
}
157-
)
158-
interface = os.environ.get("INTERFACE", "swagger-ui").lower()
159-
if interface not in ["swagger-ui", "redoc"]:
160-
interface = "swagger-ui"
161-
return templates.TemplateResponse(
162-
f"{interface}.html",
163-
{
164-
"request": request,
165-
"urls": swaggers,
166-
"title": os.environ.get("TITLE", "API Documentation"),
167-
}
168-
)
108+
with open('static/openapi/services.json', 'r') as f:
109+
services = json.load(f)
110+
logger.info(f"Loaded {len(services)} services.")
111+
except Exception as e:
112+
logger.error(f"Error loading services file: {e}")
113+
raise HTTPException(status_code=500, detail="Error loading services file.")
114+
115+
urls = []
116+
for service_name, service in services.items():
117+
urls.append({
118+
"url": f"/services/{service_name}",
119+
"name": service['name'],
120+
"header": service.get("header", ""),
121+
})
122+
123+
match template.lower():
124+
case "redoc":
125+
return templates.TemplateResponse(
126+
"redoc.html",
127+
{
128+
"request": request,
129+
"urls": urls,
130+
"title": os.environ.get("TITLE", "API Documentation"),
131+
}
132+
)
133+
case "swagger-ui":
134+
return templates.TemplateResponse(
135+
"swagger-ui.html",
136+
{
137+
"request": request,
138+
"urls": urls,
139+
"title": os.environ.get("TITLE", "API Documentation"),
140+
}
141+
)
142+
case _:
143+
logger.error(f"Invalid template: {template}")
144+
raise HTTPException(status_code=400, detail="Invalid template. Use 'redoc' or 'swagger-ui'.")
145+
169146

170147
@app.get("/config", response_class=HTMLResponse, include_in_schema=False)
171148
async def config(request: Request, user=Depends(require_login)):
172149
"""
173150
Configuration page for the OpenAPI URLs.
174151
"""
175152
try:
176-
with open('static/openapi/urls.json') as f:
177-
swaggers = json.load(f)
153+
with open('static/openapi/services.json') as f:
154+
swaggers = json.load(f).values()
178155
except Exception as e:
179156
logger.error(f"Error loading configuration file: {e}")
180157
swaggers = []

tests/test_controller.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_path_without_slash(fake_memo):
3232
logger = MagicMock()
3333
with patch("builtins.open"), patch("json.dump"):
3434
service_event(event, fake_memo, logger)
35-
key = "default/my-app"
35+
key = "default.my-app"
3636
assert key in fake_memo.apps
3737
# The final path should contain the original path
3838
assert fake_memo.apps[key]['url'].endswith("/openapi.json")
@@ -43,7 +43,7 @@ def test_path_with_slash(fake_memo):
4343
logger = MagicMock()
4444
with patch("builtins.open"), patch("json.dump"):
4545
service_event(event, fake_memo, logger)
46-
key = "default/my-app"
46+
key = "default.my-app"
4747
assert key in fake_memo.apps
4848
assert fake_memo.apps[key]['url'].endswith("/openapi.json")
4949

@@ -53,7 +53,7 @@ def test_path_with_host(fake_memo):
5353
logger = MagicMock()
5454
with patch("builtins.open"), patch("json.dump"):
5555
service_event(event, fake_memo, logger)
56-
key = "default/my-app"
56+
key = "default.my-app"
5757
assert key in fake_memo.apps
5858
# The host should be preserved
5959
assert fake_memo.apps[key]['url'].startswith("http://myhost")
@@ -72,7 +72,7 @@ def test_missing_annotation(fake_memo):
7272

7373
def test_service_event_deleted(fake_memo):
7474
# Pre-add an app to memo
75-
fake_memo.apps["default/my-app"] = {
75+
fake_memo.apps["default.my-app"] = {
7676
"url": "http://dummy",
7777
"name": "my-app",
7878
"header": "X-API-KEY"
@@ -96,4 +96,4 @@ def test_service_event_deleted(fake_memo):
9696
with patch("builtins.open"), patch("json.dump"):
9797
service_event(event, fake_memo, logger)
9898
# Should remove the app from memo
99-
assert "default/my-app" not in fake_memo.apps
99+
assert "default.my-app" not in fake_memo.apps

0 commit comments

Comments
 (0)