Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

12 changed files with 77 additions and 158 deletions

View file

@ -1,9 +0,0 @@
name: "WireGUI CodeQL config"
query-filters:
# API token hashing uses HMAC-SHA256 which is appropriate for high-entropy
# tokens (256-bit random). Actual password hashing uses bcrypt.
# CodeQL flags any SHA-family hash as "weak for password hashing" but this
# rule is not applicable to API token lookups.
- exclude:
id: py/weak-sensitive-data-hashing

View file

@ -1,32 +0,0 @@
name: CodeQL
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
schedule:
- cron: "0 6 * * 1"
jobs:
analyze:
name: Analyze (Python)
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View file

@ -23,46 +23,9 @@ jobs:
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
valkey:
image: valkey/valkey:8
options: >-
--health-cmd "valkey-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
mock-oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- 9000:9000
env:
SERVER_PORT: "9000"
JSON_CONFIG: >
{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "test-idp",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "scope",
"match": "*",
"claims": {
"sub": "$${claim:sub}",
"email": "$${claim:sub}@test.local",
"name": "Test User"
}
}
]
}
]
}
env: env:
CI: "true" CI: "true"
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
WG_REDIS_URL: redis://valkey:6379/0
MOCK_OIDC_HOST: mock-oidc
steps: steps:
- name: Install system dependencies - name: Install system dependencies
run: | run: |
@ -93,13 +56,17 @@ jobs:
needs: test needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: container:
contents: write image: node:20-slim
outputs: outputs:
new_tag: ${{ steps.version.outputs.new_tag }} new_tag: ${{ steps.version.outputs.new_tag }}
new_version: ${{ steps.version.outputs.new_version }} new_version: ${{ steps.version.outputs.new_version }}
skip: ${{ steps.version.outputs.skip }} skip: ${{ steps.version.outputs.skip }}
steps: steps:
- name: Install dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:

View file

@ -1,39 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | :white_check_mark: |
## Reporting a Vulnerability
If you discover a security vulnerability in WireGUI, please report it responsibly through **GitHub's private vulnerability reporting**:
1. Go to the [Security Advisories](https://github.com/bartei/wiregui/security/advisories) page
2. Click **"Report a vulnerability"**
3. Fill in the details of the vulnerability
Please **do not** open a public issue for security vulnerabilities.
## What to Expect
- You will receive an acknowledgment within **48 hours**
- We will provide a timeline for a fix within **7 days**
- Security patches will be released as soon as possible
## Scope
The following are in scope for security reports:
- Authentication and authorization bypasses
- SQL injection, XSS, CSRF, or other injection vulnerabilities
- WireGuard configuration issues that could expose private keys
- API token or session handling flaws
- Privilege escalation between user roles
## Out of Scope
- Denial of service (DoS) attacks
- Issues in third-party dependencies (report these upstream)
- Social engineering attacks

View file

@ -20,7 +20,7 @@ dependencies = [
"cryptography>=44", "cryptography>=44",
# Auth # Auth
"bcrypt>=4.0", "bcrypt>=4.0",
"pyjwt[crypto]>=2.9", "python-jose[cryptography]>=3.3",
"authlib>=1.4", "authlib>=1.4",
"pyotp>=2.9", "pyotp>=2.9",
"webauthn>=2.2", "webauthn>=2.2",

View file

@ -166,10 +166,8 @@ async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
async def test_seed_invalid_yaml(clean_config, monkeypatch): async def test_seed_invalid_yaml(clean_config, monkeypatch):
f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w") path = Path(tempfile.mktemp(suffix=".yaml"))
f.write(": : : invalid yaml [[[") path.write_text(": : : invalid yaml [[[")
f.close()
path = Path(f.name)
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})()) monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
await seed_idp_providers() await seed_idp_providers()
async with async_session() as session: async with async_session() as session:

View file

@ -1,6 +1,8 @@
"""Tests for REST API endpoints and token auth.""" """Tests for REST API endpoints and token auth."""
from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token import hashlib
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
from wiregui.auth.passwords import hash_password from wiregui.auth.passwords import hash_password
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.user import User from wiregui.models.user import User
@ -13,7 +15,7 @@ from wiregui.utils.time import utcnow
def test_generate_api_token(): def test_generate_api_token():
plaintext, token_hash = generate_api_token() plaintext, token_hash = generate_api_token()
assert len(plaintext) > 20 assert len(plaintext) > 20
assert token_hash == _token_hmac(plaintext) assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest()
def test_generate_api_token_unique(): def test_generate_api_token_unique():

70
uv.lock generated
View file

@ -600,6 +600,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" },
] ]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.2" version = "0.135.2"
@ -1490,20 +1502,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "pyopenssl" name = "pyopenssl"
version = "26.0.0" version = "26.0.0"
@ -1601,6 +1599,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" }, { url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.22" version = "0.0.22"
@ -1780,6 +1797,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@ -1801,6 +1830,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "smmap" name = "smmap"
version = "5.0.3" version = "5.0.3"
@ -2095,8 +2133,8 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "nicegui" }, { name = "nicegui" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyotp" }, { name = "pyotp" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python3-saml" }, { name = "python3-saml" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] }, { name = "qrcode", extra = ["pil"] },
@ -2127,8 +2165,8 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "nicegui", specifier = ">=2.12" }, { name = "nicegui", specifier = ">=2.12" },
{ name = "pydantic-settings", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.7" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pyotp", specifier = ">=2.9" }, { name = "pyotp", specifier = ">=2.9" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
{ name = "python3-saml", specifier = ">=1.16" }, { name = "python3-saml", specifier = ">=1.16" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" },

View file

@ -1,33 +1,27 @@
"""API token authentication — Bearer token via Authorization header.""" """API token authentication — Bearer token via Authorization header."""
import hmac import hashlib
import secrets import secrets
from loguru import logger from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select from sqlmodel import select
from wiregui.config import get_settings
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.user import User from wiregui.models.user import User
from wiregui.utils.time import utcnow from wiregui.utils.time import utcnow
def _token_hmac(token: str) -> str:
"""Compute a keyed HMAC-SHA256 digest of an API token."""
key = get_settings().secret_key.encode()
return hmac.new(key, token.encode(), "sha256").hexdigest()
def generate_api_token() -> tuple[str, str]: def generate_api_token() -> tuple[str, str]:
"""Generate a new API token. Returns (plaintext_token, token_hash).""" """Generate a new API token. Returns (plaintext_token, token_hash)."""
plaintext = secrets.token_urlsafe(32) plaintext = secrets.token_urlsafe(32)
return plaintext, _token_hmac(plaintext) token_hash = hashlib.sha256(plaintext.encode()).hexdigest()
return plaintext, token_hash
async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None: async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None:
"""Look up a Bearer token and return the associated user, or None.""" """Look up a Bearer token and return the associated user, or None."""
token_hash = _token_hmac(token) token_hash = hashlib.sha256(token.encode()).hexdigest()
result = await session.execute( result = await session.execute(
select(ApiToken).where(ApiToken.token_hash == token_hash) select(ApiToken).where(ApiToken.token_hash == token_hash)
) )

View file

@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import jwt from jose import JWTError, jwt
from wiregui.config import get_settings from wiregui.config import get_settings
@ -22,5 +22,5 @@ def decode_access_token(token: str) -> dict | None:
"""Decode and validate a JWT. Returns the payload dict or None if invalid/expired.""" """Decode and validate a JWT. Returns the payload dict or None if invalid/expired."""
try: try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM]) return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): except JWTError:
return None return None

View file

@ -92,8 +92,8 @@ def _start_collector() -> None:
global _collector_proc global _collector_proc
_collector_proc = subprocess.Popen( _collector_proc = subprocess.Popen(
[sys.executable, "-m", "wiregui.collector"], [sys.executable, "-m", "wiregui.collector"],
stdout=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.STDOUT,
) )
logger.info("Metrics collector started (pid={})", _collector_proc.pid) logger.info("Metrics collector started (pid={})", _collector_proc.pid)

View file

@ -57,9 +57,9 @@ async def oidc_callback(provider_id: str, request: Request):
id_token = token.get("id_token") id_token = token.get("id_token")
if id_token: if id_token:
try: try:
import jwt as pyjwt from jose import jwt as jose_jwt
# Decode without verification — we already verified during token exchange # Decode without verification — we already verified during token exchange
claims = pyjwt.decode(id_token, options={"verify_signature": False}) claims = jose_jwt.get_unverified_claims(id_token)
userinfo = userinfo or {} userinfo = userinfo or {}
if not userinfo.get("email"): if not userinfo.get("email"):
userinfo["email"] = claims.get("email") userinfo["email"] = claims.get("email")