mirror of
https://github.com/eduardogsilva/wireguard_webadmin.git
synced 2026-03-17 22:36:17 +00:00
implement CSRF protection by adding token generation, validation, and cookie management in login flows
This commit is contained in:
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
|
|||||||
config_dir: Path = Field(default=Path("/caddy_json_export"))
|
config_dir: Path = Field(default=Path("/caddy_json_export"))
|
||||||
database_path: Path = Field(default=Path("/data/auth-gateway.sqlite3"))
|
database_path: Path = Field(default=Path("/data/auth-gateway.sqlite3"))
|
||||||
cookie_name: str = Field(default="auth_gateway_session")
|
cookie_name: str = Field(default="auth_gateway_session")
|
||||||
|
csrf_cookie_name: str = Field(default="auth_gateway_csrf")
|
||||||
external_path: str = Field(default="/auth-gateway")
|
external_path: str = Field(default="/auth-gateway")
|
||||||
secure_cookies: bool = Field(default=True)
|
secure_cookies: bool = Field(default=True)
|
||||||
session_default_minutes: int = Field(default=720)
|
session_default_minutes: int = Field(default=720)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ external_path }}/login/password" class="stack">
|
<form method="post" action="{{ external_path }}/login/password" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ external_path }}/login/totp" class="stack">
|
<form method="post" action="{{ external_path }}/login/totp" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Verification code</span>
|
<span>Verification code</span>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
</table>
|
</table>
|
||||||
<hr>
|
<hr>
|
||||||
<form method="post" action="{{ external_path }}/logout">
|
<form method="post" action="{{ external_path }}/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="next" value="/">
|
<input type="hidden" name="next" value="/">
|
||||||
<button class="btn btn-danger" type="submit">Sign out</button>
|
<button class="btn btn-danger" type="submit">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from secrets import token_urlsafe
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from auth_gateway.config_loader import RuntimeConfigStore
|
from auth_gateway.config_loader import RuntimeConfigStore
|
||||||
@@ -7,6 +8,7 @@ from auth_gateway.models.session import SessionRecord
|
|||||||
from auth_gateway.services.policy_engine import EffectivePolicy, build_effective_policy
|
from auth_gateway.services.policy_engine import EffectivePolicy, build_effective_policy
|
||||||
from auth_gateway.services.resolver import RequestContext, normalize_host, normalize_path, resolve_request_context
|
from auth_gateway.services.resolver import RequestContext, normalize_host, normalize_path, resolve_request_context
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_config(request: Request) -> RuntimeConfig:
|
def get_runtime_config(request: Request) -> RuntimeConfig:
|
||||||
@@ -80,3 +82,29 @@ def get_effective_expiration(request: Request, effective_policy: EffectivePolicy
|
|||||||
settings = request.app.state.settings
|
settings = request.app.state.settings
|
||||||
expirations = [effective_policy.factor_expirations.get(factor) for factor in factors if effective_policy.factor_expirations.get(factor)]
|
expirations = [effective_policy.factor_expirations.get(factor) for factor in factors if effective_policy.factor_expirations.get(factor)]
|
||||||
return min(expirations) if expirations else settings.session_default_minutes
|
return min(expirations) if expirations else settings.session_default_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_csrf_token(request: Request) -> str:
|
||||||
|
cookie_name = request.app.state.settings.csrf_cookie_name
|
||||||
|
existing_token = request.cookies.get(cookie_name)
|
||||||
|
if existing_token:
|
||||||
|
return existing_token
|
||||||
|
return token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def set_csrf_cookie(request: Request, response: Response, csrf_token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=request.app.state.settings.csrf_cookie_name,
|
||||||
|
value=csrf_token,
|
||||||
|
httponly=False,
|
||||||
|
secure=request.app.state.settings.secure_cookies,
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_csrf(request: Request, submitted_token: str | None) -> None:
|
||||||
|
cookie_name = request.app.state.settings.csrf_cookie_name
|
||||||
|
cookie_token = request.cookies.get(cookie_name)
|
||||||
|
if not cookie_token or not submitted_token or cookie_token != submitted_token:
|
||||||
|
raise HTTPException(status_code=403, detail="CSRF validation failed.")
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ from auth_gateway.web.dependencies import (
|
|||||||
get_effective_expiration,
|
get_effective_expiration,
|
||||||
get_effective_policy,
|
get_effective_policy,
|
||||||
get_oidc_method,
|
get_oidc_method,
|
||||||
|
get_or_create_csrf_token,
|
||||||
get_runtime_config,
|
get_runtime_config,
|
||||||
get_session,
|
get_session,
|
||||||
get_totp_method,
|
get_totp_method,
|
||||||
resolve_context_from_request,
|
resolve_context_from_request,
|
||||||
|
set_csrf_cookie,
|
||||||
session_is_allowed,
|
session_is_allowed,
|
||||||
|
validate_csrf,
|
||||||
)
|
)
|
||||||
from auth_gateway.limiter import AUTH_RATE_LIMIT, limiter
|
from auth_gateway.limiter import AUTH_RATE_LIMIT, limiter
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -33,7 +36,11 @@ def _render(request: Request, template_name: str, status_code: int = 200, **cont
|
|||||||
"external_path": request.app.state.settings.external_path,
|
"external_path": request.app.state.settings.external_path,
|
||||||
}
|
}
|
||||||
base_context.update(context)
|
base_context.update(context)
|
||||||
return templates.TemplateResponse(template_name, base_context, status_code=status_code)
|
response = templates.TemplateResponse(template_name, base_context, status_code=status_code)
|
||||||
|
csrf_token = context.get("csrf_token")
|
||||||
|
if csrf_token:
|
||||||
|
set_csrf_cookie(request, response, csrf_token)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _redirect_with_cookie(request: Request, destination: str, session) -> RedirectResponse:
|
def _redirect_with_cookie(request: Request, destination: str, session) -> RedirectResponse:
|
||||||
@@ -49,12 +56,40 @@ def _redirect_with_cookie(request: Request, destination: str, session) -> Redire
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _create_authenticated_session(request: Request, *, username=None, email=None, subject=None, groups=None, metadata=None, factors=None, expires_in_minutes=None):
|
||||||
|
existing_session = get_session(request)
|
||||||
|
if existing_session:
|
||||||
|
request.app.state.session_service.delete_session(existing_session.session_id)
|
||||||
|
return request.app.state.session_service.issue_session(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
subject=subject,
|
||||||
|
groups=groups,
|
||||||
|
metadata=metadata,
|
||||||
|
add_factors=factors,
|
||||||
|
expires_in_minutes=expires_in_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf_error(request: Request, next_path: str, application_name: str | None = None):
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
status_code=403,
|
||||||
|
title="Invalid form submission",
|
||||||
|
message="The form security token is missing or invalid. Please try again.",
|
||||||
|
next=next_path,
|
||||||
|
application_name=application_name,
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def session_page(request: Request):
|
async def session_page(request: Request):
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
if not session or not session.auth_factors:
|
if not session or not session.auth_factors:
|
||||||
return RedirectResponse(build_external_url(request, "/login"), status_code=303)
|
return RedirectResponse(build_external_url(request, "/login"), status_code=303)
|
||||||
return _render(request, "session.html", session=session)
|
return _render(request, "session.html", session=session, csrf_token=get_or_create_csrf_token(request))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
@@ -117,36 +152,68 @@ async def login_password_page(request: Request, next: str = "/"):
|
|||||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||||
if not effective_policy.password_method_names:
|
if not effective_policy.password_method_names:
|
||||||
return _render(request, "error.html", status_code=400, title="Password login unavailable", message="The selected policy does not require a password step.")
|
return _render(request, "error.html", status_code=400, title="Password login unavailable", message="The selected policy does not require a password step.")
|
||||||
return _render(request, "login_password.html", next=next, application_name=context.application.name, error=None)
|
return _render(
|
||||||
|
request,
|
||||||
|
"login_password.html",
|
||||||
|
next=next,
|
||||||
|
application_name=context.application.name,
|
||||||
|
error=None,
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login/password")
|
@router.post("/login/password")
|
||||||
@limiter.limit(AUTH_RATE_LIMIT)
|
@limiter.limit(AUTH_RATE_LIMIT)
|
||||||
async def login_password_submit(request: Request, next: str = Form("/"), username: str = Form(...), password: str = Form(...)):
|
async def login_password_submit(
|
||||||
|
request: Request,
|
||||||
|
next: str = Form("/"),
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
runtime_config = get_runtime_config(request)
|
runtime_config = get_runtime_config(request)
|
||||||
context = resolve_context_from_request(request, runtime_config, next)
|
context = resolve_context_from_request(request, runtime_config, next)
|
||||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||||
|
try:
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
except HTTPException:
|
||||||
|
return _csrf_error(request, next, context.application.name)
|
||||||
user = verify_user_password(username, password, runtime_config.users)
|
user = verify_user_password(username, password, runtime_config.users)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
logger.warning("AUTH password failed for '%s' (policy: %s)", username, context.policy_name)
|
logger.warning("AUTH password failed for '%s' (policy: %s)", username, context.policy_name)
|
||||||
return _render(request, "login_password.html", status_code=401, next=next, application_name=context.application.name, error="Invalid username or password.")
|
return _render(
|
||||||
|
request,
|
||||||
|
"login_password.html",
|
||||||
|
status_code=401,
|
||||||
|
next=next,
|
||||||
|
application_name=context.application.name,
|
||||||
|
error="Invalid username or password.",
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
if effective_policy.allowed_users and username not in effective_policy.allowed_users:
|
if effective_policy.allowed_users and username not in effective_policy.allowed_users:
|
||||||
logger.warning("AUTH password denied for '%s' — not in allowed_users (policy: %s)", username, context.policy_name)
|
logger.warning("AUTH password denied for '%s' — not in allowed_users (policy: %s)", username, context.policy_name)
|
||||||
return _render(request, "login_password.html", status_code=403, next=next, application_name=context.application.name, error="This user is not allowed by the active policy.")
|
return _render(
|
||||||
|
request,
|
||||||
|
"login_password.html",
|
||||||
|
status_code=403,
|
||||||
|
next=next,
|
||||||
|
application_name=context.application.name,
|
||||||
|
error="This user is not allowed by the active policy.",
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
|
|
||||||
groups = [
|
groups = [
|
||||||
group_name
|
group_name
|
||||||
for group_name, group in runtime_config.groups.items()
|
for group_name, group in runtime_config.groups.items()
|
||||||
if username in group.users
|
if username in group.users
|
||||||
]
|
]
|
||||||
session_service = request.app.state.session_service
|
session = _create_authenticated_session(
|
||||||
session = session_service.issue_session(
|
request,
|
||||||
existing_session=get_session(request),
|
|
||||||
username=username,
|
username=username,
|
||||||
email=user.email or None,
|
email=user.email or None,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
add_factors=["password"],
|
factors=["password"],
|
||||||
expires_in_minutes=get_effective_expiration(request, effective_policy, ["password"]),
|
expires_in_minutes=get_effective_expiration(request, effective_policy, ["password"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,16 +235,27 @@ async def login_totp_page(request: Request, next: str = "/"):
|
|||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
if not session or "password" not in session.auth_factors:
|
if not session or "password" not in session.auth_factors:
|
||||||
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
||||||
return _render(request, "login_totp.html", next=next, application_name=context.application.name, error=None)
|
return _render(
|
||||||
|
request,
|
||||||
|
"login_totp.html",
|
||||||
|
next=next,
|
||||||
|
application_name=context.application.name,
|
||||||
|
error=None,
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login/totp")
|
@router.post("/login/totp")
|
||||||
@limiter.limit(AUTH_RATE_LIMIT)
|
@limiter.limit(AUTH_RATE_LIMIT)
|
||||||
async def login_totp_submit(request: Request, next: str = Form("/"), token: str = Form(...)):
|
async def login_totp_submit(request: Request, next: str = Form("/"), token: str = Form(...), csrf_token: str = Form(...)):
|
||||||
runtime_config = get_runtime_config(request)
|
runtime_config = get_runtime_config(request)
|
||||||
context = resolve_context_from_request(request, runtime_config, next)
|
context = resolve_context_from_request(request, runtime_config, next)
|
||||||
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
effective_policy = get_effective_policy(runtime_config, context.policy_name)
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
try:
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
except HTTPException:
|
||||||
|
return _csrf_error(request, next, context.application.name)
|
||||||
|
|
||||||
if effective_policy.password_method_names and (not session or "password" not in session.auth_factors):
|
if effective_policy.password_method_names and (not session or "password" not in session.auth_factors):
|
||||||
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
return RedirectResponse(build_external_url(request, "/login/password", next=next), status_code=303)
|
||||||
@@ -196,7 +274,15 @@ async def login_totp_submit(request: Request, next: str = Form("/"), token: str
|
|||||||
|
|
||||||
if not verify_totp(secret, token):
|
if not verify_totp(secret, token):
|
||||||
logger.warning("AUTH totp failed for '%s' (policy: %s)", session.username if session else "?", context.policy_name)
|
logger.warning("AUTH totp failed for '%s' (policy: %s)", session.username if session else "?", context.policy_name)
|
||||||
return _render(request, "login_totp.html", status_code=401, next=next, application_name=context.application.name, error="Invalid verification code.")
|
return _render(
|
||||||
|
request,
|
||||||
|
"login_totp.html",
|
||||||
|
status_code=401,
|
||||||
|
next=next,
|
||||||
|
application_name=context.application.name,
|
||||||
|
error="Invalid verification code.",
|
||||||
|
csrf_token=get_or_create_csrf_token(request),
|
||||||
|
)
|
||||||
|
|
||||||
session_service = request.app.state.session_service
|
session_service = request.app.state.session_service
|
||||||
refreshed_session = session_service.issue_session(
|
refreshed_session = session_service.issue_session(
|
||||||
@@ -252,12 +338,12 @@ async def login_oidc_callback(request: Request, state: str):
|
|||||||
if not is_oidc_identity_allowed(method, identity.email):
|
if not is_oidc_identity_allowed(method, identity.email):
|
||||||
return _render(request, "error.html", status_code=403, title="OIDC access denied", message="The authenticated OIDC identity is not allowed by the configured allowlists.")
|
return _render(request, "error.html", status_code=403, title="OIDC access denied", message="The authenticated OIDC identity is not allowed by the configured allowlists.")
|
||||||
|
|
||||||
session = request.app.state.session_service.issue_session(
|
session = _create_authenticated_session(
|
||||||
existing_session=get_session(request),
|
request,
|
||||||
email=identity.email,
|
email=identity.email,
|
||||||
subject=identity.subject,
|
subject=identity.subject,
|
||||||
metadata={"oidc_claims": identity.claims},
|
metadata={"oidc_claims": identity.claims},
|
||||||
add_factors=["oidc"],
|
factors=["oidc"],
|
||||||
expires_in_minutes=get_effective_expiration(request, effective_policy, ["oidc"]),
|
expires_in_minutes=get_effective_expiration(request, effective_policy, ["oidc"]),
|
||||||
)
|
)
|
||||||
if effective_policy.totp_method_names:
|
if effective_policy.totp_method_names:
|
||||||
@@ -289,5 +375,9 @@ async def logout_get(request: Request, next: str = "/"):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout_post(request: Request, next: str = Form("/")):
|
async def logout_post(request: Request, next: str = Form("/"), csrf_token: str = Form(...)):
|
||||||
|
try:
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
except HTTPException:
|
||||||
|
return _csrf_error(request, next)
|
||||||
return _do_logout(request, next)
|
return _do_logout(request, next)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import importlib.util
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,6 +9,13 @@ from auth_gateway.services.policy_engine import build_effective_policy, evaluate
|
|||||||
from auth_gateway.services.resolver import resolve_request_context
|
from auth_gateway.services.resolver import resolve_request_context
|
||||||
from auth_gateway.services.totp_service import verify_totp
|
from auth_gateway.services.totp_service import verify_totp
|
||||||
|
|
||||||
|
_PROCESS_CONFIG_PATH = Path(__file__).resolve().parents[2] / "caddy" / "process_config.py"
|
||||||
|
_PROCESS_CONFIG_SPEC = importlib.util.spec_from_file_location("caddy_process_config", _PROCESS_CONFIG_PATH)
|
||||||
|
_PROCESS_CONFIG_MODULE = importlib.util.module_from_spec(_PROCESS_CONFIG_SPEC)
|
||||||
|
assert _PROCESS_CONFIG_SPEC and _PROCESS_CONFIG_SPEC.loader
|
||||||
|
_PROCESS_CONFIG_SPEC.loader.exec_module(_PROCESS_CONFIG_MODULE)
|
||||||
|
build_caddyfile = _PROCESS_CONFIG_MODULE.build_caddyfile
|
||||||
|
|
||||||
|
|
||||||
class AuthGatewayConfigTests(unittest.TestCase):
|
class AuthGatewayConfigTests(unittest.TestCase):
|
||||||
def test_existing_config_loads_and_resolves_routes(self):
|
def test_existing_config_loads_and_resolves_routes(self):
|
||||||
@@ -58,6 +66,40 @@ class AuthGatewayConfigTests(unittest.TestCase):
|
|||||||
token = pyotp.TOTP(secret).now()
|
token = pyotp.TOTP(secret).now()
|
||||||
self.assertTrue(verify_totp(secret, token))
|
self.assertTrue(verify_totp(secret, token))
|
||||||
|
|
||||||
|
def test_caddyfile_uses_boundary_matchers_and_clears_identity_headers(self):
|
||||||
|
caddyfile = build_caddyfile(
|
||||||
|
apps=[
|
||||||
|
{
|
||||||
|
"id": "app-one",
|
||||||
|
"hosts": ["app.example.com"],
|
||||||
|
"upstream": "http://backend:8080",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
auth_policies={
|
||||||
|
"policies": {
|
||||||
|
"public": {"policy_type": "bypass"},
|
||||||
|
"protected": {"policy_type": "protected"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
routes={
|
||||||
|
"entries": {
|
||||||
|
"app-one": {
|
||||||
|
"routes": [
|
||||||
|
{"path_prefix": "/public", "policy": "public"},
|
||||||
|
],
|
||||||
|
"default_policy": "protected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("@route_app_one_0 {", caddyfile)
|
||||||
|
self.assertIn("path /public /public/*", caddyfile)
|
||||||
|
self.assertIn("request_header -X-Auth-User", caddyfile)
|
||||||
|
self.assertIn("request_header -X-Auth-Email", caddyfile)
|
||||||
|
self.assertIn("handle @route_app_one_0 {", caddyfile)
|
||||||
|
self.assertNotIn("handle /public*", caddyfile)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Expected input files in /caddy_json_export/:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
JSON_DIR = os.environ.get("JSON_DIR", "/caddy_json_export")
|
JSON_DIR = os.environ.get("JSON_DIR", "/caddy_json_export")
|
||||||
@@ -18,6 +19,13 @@ CADDYFILE_PATH = os.environ.get("CADDYFILE_PATH", "/etc/caddy/Caddyfile")
|
|||||||
AUTH_GATEWAY_INTERNAL_URL = os.environ.get("AUTH_GATEWAY_INTERNAL_URL", "http://wireguard-webadmin-auth-gateway:9091")
|
AUTH_GATEWAY_INTERNAL_URL = os.environ.get("AUTH_GATEWAY_INTERNAL_URL", "http://wireguard-webadmin-auth-gateway:9091")
|
||||||
AUTH_GATEWAY_PORTAL_PATH = os.environ.get("AUTH_GATEWAY_EXTERNAL_PATH", "/auth-gateway")
|
AUTH_GATEWAY_PORTAL_PATH = os.environ.get("AUTH_GATEWAY_EXTERNAL_PATH", "/auth-gateway")
|
||||||
AUTH_GATEWAY_CHECK_URI = "/auth/check"
|
AUTH_GATEWAY_CHECK_URI = "/auth/check"
|
||||||
|
AUTH_IDENTITY_HEADERS = (
|
||||||
|
"X-Auth-User",
|
||||||
|
"X-Auth-Email",
|
||||||
|
"X-Auth-Groups",
|
||||||
|
"X-Auth-Factors",
|
||||||
|
"X-Auth-Policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_json(filename):
|
def load_json(filename):
|
||||||
@@ -69,8 +77,25 @@ def build_caddyfile(apps, auth_policies, routes):
|
|||||||
lines.append(" }")
|
lines.append(" }")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
def handle_open(matcher):
|
def emit_identity_header_sanitization(indent=" "):
|
||||||
if matcher == "*":
|
for header_name in AUTH_IDENTITY_HEADERS:
|
||||||
|
lines.append(f"{indent}request_header -{header_name}")
|
||||||
|
|
||||||
|
def emit_route_matcher(matcher_name, path_prefix):
|
||||||
|
matcher_name = re.sub(r"[^A-Za-z0-9_]", "_", matcher_name)
|
||||||
|
normalized_prefix = path_prefix.strip().rstrip("/") or "/"
|
||||||
|
if not normalized_prefix.startswith("/"):
|
||||||
|
normalized_prefix = f"/{normalized_prefix}"
|
||||||
|
if normalized_prefix == "/":
|
||||||
|
return None
|
||||||
|
lines.append(f" @{matcher_name} {{")
|
||||||
|
lines.append(f" path {normalized_prefix} {normalized_prefix}/*")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append("")
|
||||||
|
return f"@{matcher_name}"
|
||||||
|
|
||||||
|
def handle_open(matcher=None):
|
||||||
|
if not matcher:
|
||||||
return " handle {"
|
return " handle {"
|
||||||
return f" handle {matcher} {{"
|
return f" handle {matcher} {{"
|
||||||
|
|
||||||
@@ -110,8 +135,9 @@ def build_caddyfile(apps, auth_policies, routes):
|
|||||||
|
|
||||||
lines.append(f"{', '.join(hosts)} {{")
|
lines.append(f"{', '.join(hosts)} {{")
|
||||||
lines.append(" # Security: overwrite client-supplied forwarding headers with verified values")
|
lines.append(" # Security: overwrite client-supplied forwarding headers with verified values")
|
||||||
lines.append(" request_header X-Forwarded-For {remote_host}")
|
lines.append(" request_header X-Forwarded-For {http.request.remote.host}")
|
||||||
lines.append(" request_header -X-Forwarded-Host")
|
lines.append(" request_header -X-Forwarded-Host")
|
||||||
|
emit_identity_header_sanitization()
|
||||||
lines.append("")
|
lines.append("")
|
||||||
emit_auth_portal()
|
emit_auth_portal()
|
||||||
|
|
||||||
@@ -135,10 +161,10 @@ def build_caddyfile(apps, auth_policies, routes):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
route_list = sorted(app_route_data.get("routes", []), key=lambda route: len(route.get("path_prefix", "")), reverse=True)
|
route_list = sorted(app_route_data.get("routes", []), key=lambda route: len(route.get("path_prefix", "")), reverse=True)
|
||||||
for route in route_list:
|
for route_index, route in enumerate(route_list):
|
||||||
path_prefix = route.get("path_prefix", "/")
|
path_prefix = route.get("path_prefix", "/")
|
||||||
policy_type = get_policy_type(route.get("policy"))
|
policy_type = get_policy_type(route.get("policy"))
|
||||||
matcher = f"{path_prefix}*"
|
matcher = emit_route_matcher(f"route_{app_id}_{route_index}", path_prefix)
|
||||||
if policy_type == "bypass":
|
if policy_type == "bypass":
|
||||||
lines.append(handle_open(matcher))
|
lines.append(handle_open(matcher))
|
||||||
emit_reverse_proxy(base, upstream_path, allow_invalid_cert=allow_invalid_cert)
|
emit_reverse_proxy(base, upstream_path, allow_invalid_cert=allow_invalid_cert)
|
||||||
@@ -158,7 +184,7 @@ def build_caddyfile(apps, auth_policies, routes):
|
|||||||
elif default_policy_type == "deny":
|
elif default_policy_type == "deny":
|
||||||
lines.append(" respond 403")
|
lines.append(" respond 403")
|
||||||
else:
|
else:
|
||||||
emit_protected_handle("*", base, upstream_path, allow_invalid_cert=allow_invalid_cert)
|
emit_protected_handle(None, base, upstream_path, allow_invalid_cert=allow_invalid_cert)
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user