11 Commits

Author SHA1 Message Date
herel 071324d199 fix: Weak JWT Validation 2026-06-29 16:48:02 +02:00
herel 51ee0b6b32 fix: No static key fallback; reject non-OIDC tokens 2026-06-29 16:43:09 +02:00
herel 9c99787b1f fix: remove hard coded old secret 2026-06-29 16:42:51 +02:00
herel 8bb89eabdc fix: Permissive CORS Configuration 2026-06-29 16:40:36 +02:00
herel eca5eacd70 fix: Missing Authentication Checks 2026-06-29 16:37:39 +02:00
herel 5e17fd27d6 fix(security): Authentication Bypass via Broken JWT Validation in AuthHeaders.validate_oidc_token 2026-06-29 16:33:21 +02:00
herel 9dcf4f6fd3 chore: add some debug 2026-06-29 16:30:35 +02:00
herel ac8e252a2b chore: update categories 2026-06-29 16:28:15 +02:00
herel 1863f5586b fix: prevent OS Command Injection 2026-06-29 16:19:33 +02:00
herel c83bb2af9b fix(schedule): Use midnight as schedule start date
Change date_start from current time to today at midnight so the
schedule fetch query properly covers the full day.
2026-06-05 15:12:33 +02:00
herel 4c90716355 chore: update Dockerfile 2025-11-11 11:06:40 +01:00
7 changed files with 942 additions and 706 deletions
+24 -14
View File
@@ -10,28 +10,38 @@ COPY requirements.txt ./
# Install dependencies to a target directory # Install dependencies to a target directory
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --no-deps --disable-pip-version-check --target=/app/site-packages -r requirements.txt pip install --no-deps --disable-pip-version-check -r requirements.txt
# Use Alpine as the base image for a much smaller footprint # Runtime stage
FROM python:3.13-slim FROM python:3.13-slim AS runtime
# Copy installed packages from builder stage # Create working directory
COPY --from=builder /app/site-packages /app/site-packages
# Copy application code
COPY index.html favicon.ico /app/
COPY myice /app/myice
# Set PYTHONPATH so Python can find our installed packages
ENV PYTHONPATH=/app/site-packages
# Set working directory
WORKDIR /app WORKDIR /app
# Install only runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for security # Create a non-root user for security
RUN useradd --home-dir /app --no-create-home --uid 1000 myice RUN useradd --home-dir /app --no-create-home --uid 1000 myice
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# Copy application code
COPY index.html favicon.ico ./
COPY myice ./myice
# Change ownership of copied files
RUN chown -R myice:myice /app
# Switch to non-root user
USER myice USER myice
# Bytecompile Python files for faster first load
RUN python -m compileall -q ./myice
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
REGISTRY="harbor.cl1.parano.ch/library"
IMAGE="myice"
current_commit=$(git rev-parse HEAD)
git_tag=$(git tag --points-at "$current_commit")
if [[ -z $git_tag ]]; then
echo "Build only works on a git tag" >&2
exit 1
fi
docker build --platform linux/amd64 -t "$REGISTRY/$IMAGE:$git_tag" .
docker push "$REGISTRY/$IMAGE:$git_tag"
+35 -7
View File
@@ -140,14 +140,14 @@ class AgeGroup(str, Enum):
u13a = "U13 (A)" u13a = "U13 (A)"
u13p = "U13 (Prép)" u13p = "U13 (Prép)"
u14ev = "U14 (Elite Vernets)" u14ev = "U14 (Elite Vernets)"
u14sae = "U14 (Groupe SAE)"
u14esmv = "U14 (Elite Sous-Moulin/Vergers)" u14esmv = "U14 (Elite Sous-Moulin/Vergers)"
u15t = "U15 (Top)" u15t = "U15 (Top)"
u15a = "U15 (A)" u15a = "U15 (A)"
u16e = "U16 (Elite)" u16e = "U16 (Elit)"
u16t = "U16 (Top GS Ass)" u16t = "U16 (Top GS Ass)"
u18e = "U18 (Elite)" u18e = "U18 (Elit)"
u18el = "U18 (Elit)" u21e = "U21 (Elit)"
u21e = "U21 (ELIT)"
u14t = "U14 (Top)" u14t = "U14 (Top)"
u14t1 = "U14 (Top1)" u14t1 = "U14 (Top1)"
u14t2 = "U14 (Top2)" u14t2 = "U14 (Top2)"
@@ -334,7 +334,7 @@ def get_schedule(num_days: int) -> str:
global userid global userid
assert session and userid assert session and userid
now = datetime.datetime.now() now = datetime.datetime.now()
date_start = now date_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start + datetime.timedelta(days=num_days) date_end = date_start + datetime.timedelta(days=num_days)
r = session.post( r = session.post(
"https://app.myice.hockey/inc/processclubplanning.php", "https://app.myice.hockey/inc/processclubplanning.php",
@@ -418,11 +418,13 @@ def schedule(
def os_open(file: str) -> None: def os_open(file: str) -> None:
import subprocess
if os.uname().sysname == "Linux": if os.uname().sysname == "Linux":
os.system(f"xdg-open {file}") subprocess.run(["xdg-open", file])
else: else:
print(f"Opening file {file}", file=sys.stderr) print(f"Opening file {file}", file=sys.stderr)
os.system(f"open {file}") subprocess.run(["open", file])
def extract_players(pdf_file: Path) -> List[str]: def extract_players(pdf_file: Path) -> List[str]:
@@ -592,6 +594,18 @@ mobile_headers = {
} }
def curl_for_mobile_login(username: str, password: str) -> str:
"""Generate curl command to replicate mobile_login request."""
import base64
headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
encoded_username = base64.b64encode(username.encode()).decode()
encoded_password = base64.b64encode(password.encode()).decode()
return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/login" \
{headers_str} \
-d 'login_email={encoded_username}&login_password={encoded_password}&language=FR&v=2'"""
def mobile_login(config_section: str | None = None): def mobile_login(config_section: str | None = None):
global global_config_section global global_config_section
import base64 import base64
@@ -604,6 +618,8 @@ def mobile_login(config_section: str | None = None):
return {"id": 0, "token": token, "id_club": club_id} return {"id": 0, "token": token, "id_club": club_id}
print("Requesting token", file=sys.stderr) print("Requesting token", file=sys.stderr)
# Print curl equivalent for debugging
print(curl_for_mobile_login(username, password), file=sys.stderr)
with requests.post( with requests.post(
"https://app.myice.hockey/api/mobilerest/login", "https://app.myice.hockey/api/mobilerest/login",
headers=mobile_headers, headers=mobile_headers,
@@ -626,7 +642,19 @@ userdata = {
} }
def curl_for_refresh_data(token: str, club_id: int) -> str:
"""Generate curl command to replicate refresh_data request."""
headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/refreshdata" \
{headers_str} \
-d 'token={token}&id_club={club_id}&language=FR'"""
def refresh_data(): def refresh_data():
# Print curl equivalent for debugging
print(
curl_for_refresh_data(userdata["token"], userdata["id_club"]), file=sys.stderr
)
with requests.post( with requests.post(
"https://app.myice.hockey/api/mobilerest/refreshdata", "https://app.myice.hockey/api/mobilerest/refreshdata",
headers=mobile_headers, headers=mobile_headers,
+40 -39
View File
@@ -10,9 +10,9 @@ from . import myice
# OIDC Configuration # OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f") CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get( CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ" if not CLIENT_SECRET:
) raise RuntimeError("CLIENT_SECRET environment variable must be set")
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback") REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize" AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token" TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
@@ -26,16 +26,19 @@ ALLOWED_USERS = (
else [] else []
) )
origins = ["*"] origins = (
os.environ.get("ALLOWED_ORIGINS", "").split(",")
if os.environ.get("ALLOWED_ORIGINS")
else []
)
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
block_endpoints = ["/health"] block_endpoints = ["/health"]
@@ -70,50 +73,36 @@ class AuthHeaders(BaseModel):
# First check if it's a valid OIDC token # First check if it's a valid OIDC token
if self.validate_oidc_token(): if self.validate_oidc_token():
return True return True
# Fallback to the old static key for compatibility # No static key fallback; reject non-OIDC tokens
if token == "abc":
return True
return False return False
def validate_oidc_token(self) -> bool: def validate_oidc_token(self) -> bool:
try: try:
import jwt
token = self.token() token = self.token()
if not token: if not token:
return False return False
# Basic validation - in production, you would validate the JWT signature # Fetch the JWKS and validate the JWT properly
# and check issuer, audience, expiration, etc. # This example uses PyJWT with a JWKSet; install with `pip install pyjwt[crypto]`
import base64 from jwt import PyJWKClient
import json
# Decode JWT header and payload (without verification for simplicity in this example) jwks_client = PyJWKClient(JWKS_URI)
parts = token.split(".") signing_key = jwks_client.get_signing_key_from_jwt(token)
if len(parts) != 3: payload_data = jwt.decode(
# If not a standard JWT, treat as opaque token token,
# For now, we'll accept any non-empty token as valid for testing signing_key.key,
return len(token) > 0 algorithms=["RS256"],
audience=CLIENT_ID,
# Decode payload (part 1) issuer="https://login.infomaniak.com/",
payload = parts[1] )
if not payload:
return False
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded_payload = base64.urlsafe_b64decode(payload)
payload_data = json.loads(decoded_payload)
# Check if user is in allowed list
user_email = payload_data.get("email") user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS: if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return False return False
return True return True
except Exception as e: except Exception:
print(f"Token validation error: {e}") return False
# Even if we can't validate the token, if it exists, we'll accept it for now
# This is for development/testing purposes only
return len(self.token()) > 0
class HealthCheck(BaseModel): class HealthCheck(BaseModel):
@@ -210,8 +199,14 @@ async def callback(code: str, state: str):
@app.get("/exchange-token") @app.get("/exchange-token")
async def exchange_token(code: str): async def exchange_token(
code: str,
headers: Annotated[AuthHeaders, Header()],
):
"""Exchange authorization code for access token""" """Exchange authorization code for access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
# Exchange authorization code for access token # Exchange authorization code for access token
token_data = { token_data = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
@@ -254,6 +249,9 @@ async def exchange_token(code: str):
@app.get("/userinfo") @app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]): async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token""" """Get user info from access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
access_token = headers.token() access_token = headers.token()
userinfo_response = requests.get( userinfo_response = requests.get(
@@ -357,6 +355,9 @@ async def game(
game_id: int, game_id: int,
account: str = "default", account: str = "default",
): ):
if not headers.authorized():
raise HTTPException(401, detail="get out")
username, password, userid, existing_token, club_id = myice.get_login( username, password, userid, existing_token, club_id = myice.get_login(
config_section=account config_section=account
) )
Generated
+803 -626
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "myice" name = "myice"
version = "v0.6.0" version = "v0.6.1"
description = "myice parsing" description = "myice parsing"
authors = [ authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"}, { name = "Rene Luria", "email" = "<rene@luria.ch>"},
@@ -15,6 +15,7 @@ dependencies = [
"pypdf (>=6.0.0)", "pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0", "rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11)", "fastapi[standard] (>=0.115.11)",
"pyjwt[crypto] (>=2.10.1)",
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
+20 -19
View File
@@ -1,46 +1,47 @@
--extra-index-url https://pypi.purple.infomaniak.ch --extra-index-url https://pypi.purple.infomaniak.ch
annotated-doc==0.0.4 ; python_version >= "3.13"
annotated-types==0.7.0 ; python_version >= "3.13" annotated-types==0.7.0 ; python_version >= "3.13"
anyio==4.11.0 ; python_version >= "3.13" anyio==4.11.0 ; python_version >= "3.13"
certifi==2025.8.3 ; python_version >= "3.13" certifi==2025.10.5 ; python_version >= "3.13"
charset-normalizer==3.4.3 ; python_version >= "3.13" charset-normalizer==3.4.4 ; python_version >= "3.13"
click==8.1.8 ; python_version >= "3.13" click==8.1.8 ; python_version >= "3.13"
colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and python_version >= "3.13" colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and python_version >= "3.13"
dnspython==2.8.0 ; python_version >= "3.13" dnspython==2.8.0 ; python_version >= "3.13"
email-validator==2.3.0 ; python_version >= "3.13" email-validator==2.3.0 ; python_version >= "3.13"
fastapi-cli==0.0.13 ; python_version >= "3.13" fastapi-cli==0.0.16 ; python_version >= "3.13"
fastapi-cloud-cli==0.2.1 ; python_version >= "3.13" fastapi-cloud-cli==0.3.1 ; python_version >= "3.13"
fastapi==0.118.0 ; python_version >= "3.13" fastapi==0.121.1 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13" h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13" httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.6.4 ; python_version >= "3.13" httptools==0.7.1 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13" httpx==0.28.1 ; python_version >= "3.13"
idna==3.10 ; python_version >= "3.13" idna==3.11 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13" jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.0.0 ; python_version >= "3.13" markdown-it-py==4.0.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13" markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13" mdurl==0.1.2 ; python_version >= "3.13"
pydantic-core==2.33.2 ; python_version >= "3.13" pydantic-core==2.41.5 ; python_version >= "3.13"
pydantic==2.11.9 ; python_version >= "3.13" pydantic==2.12.4 ; python_version >= "3.13"
pygments==2.19.2 ; python_version >= "3.13" pygments==2.19.2 ; python_version >= "3.13"
pypdf==6.1.1 ; python_version >= "3.13" pypdf==6.2.0 ; python_version >= "3.13"
python-dotenv==1.1.1 ; python_version >= "3.13" python-dotenv==1.2.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13" python-multipart==0.0.20 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13" pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.32.5 ; python_version >= "3.13" requests==2.32.5 ; python_version >= "3.13"
rich-toolkit==0.15.1 ; python_version >= "3.13" rich-toolkit==0.15.1 ; python_version >= "3.13"
rich==14.1.0 ; python_version >= "3.13" rich==14.2.0 ; python_version >= "3.13"
rignore==0.6.4 ; python_version >= "3.13" rignore==0.7.6 ; python_version >= "3.13"
rl-ai-tools==1.15.0 ; python_version >= "3.13" rl-ai-tools==1.15.0 ; python_version >= "3.13"
sentry-sdk==2.39.0 ; python_version >= "3.13" sentry-sdk==2.43.0 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13" shellingham==1.5.4 ; python_version >= "3.13"
sniffio==1.3.1 ; python_version >= "3.13" sniffio==1.3.1 ; python_version >= "3.13"
starlette==0.48.0 ; python_version >= "3.13" starlette==0.49.3 ; python_version >= "3.13"
typer==0.15.4 ; python_version >= "3.13" typer==0.15.4 ; python_version >= "3.13"
typing-extensions==4.15.0 ; python_version >= "3.13" typing-extensions==4.15.0 ; python_version >= "3.13"
typing-inspection==0.4.1 ; python_version >= "3.13" typing-inspection==0.4.2 ; python_version >= "3.13"
urllib3==2.5.0 ; python_version >= "3.13" urllib3==2.5.0 ; python_version >= "3.13"
uvicorn==0.37.0 ; python_version >= "3.13" uvicorn==0.38.0 ; python_version >= "3.13"
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13" uvloop==0.22.1 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13"
watchfiles==1.1.0 ; python_version >= "3.13" watchfiles==1.1.1 ; python_version >= "3.13"
websockets==15.0.1 ; python_version >= "3.13" websockets==15.0.1 ; python_version >= "3.13"