Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f63f4cd17 | ||
| cca49ca2cf | |||
|
|
31b31b7946 | ||
|
|
604446f8ca | ||
|
|
496334137d | ||
|
|
5c02598a46 | ||
|
|
aa38c3797e | ||
|
|
87989b899d | ||
|
|
bde7a82224 | ||
|
|
aaddb319bc |
12 changed files with 158 additions and 77 deletions
9
.github/codeql/codeql-config.yml
vendored
Normal file
9
.github/codeql/codeql-config.yml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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
|
||||
32
.github/workflows/codeql.yml
vendored
Normal file
32
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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
|
||||
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
|
|
@ -23,9 +23,46 @@ jobs:
|
|||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--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:
|
||||
CI: "true"
|
||||
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
||||
WG_REDIS_URL: redis://valkey:6379/0
|
||||
MOCK_OIDC_HOST: mock-oidc
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
|
|
@ -56,17 +93,13 @@ jobs:
|
|||
needs: test
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:20-slim
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
new_tag: ${{ steps.version.outputs.new_tag }}
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
skip: ${{ steps.version.outputs.skip }}
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
|
|||
39
SECURITY.md
Normal file
39
SECURITY.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# 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
|
||||
|
|
@ -20,7 +20,7 @@ dependencies = [
|
|||
"cryptography>=44",
|
||||
# Auth
|
||||
"bcrypt>=4.0",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
"pyjwt[crypto]>=2.9",
|
||||
"authlib>=1.4",
|
||||
"pyotp>=2.9",
|
||||
"webauthn>=2.2",
|
||||
|
|
|
|||
|
|
@ -166,8 +166,10 @@ async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
|
|||
|
||||
|
||||
async def test_seed_invalid_yaml(clean_config, monkeypatch):
|
||||
path = Path(tempfile.mktemp(suffix=".yaml"))
|
||||
path.write_text(": : : invalid yaml [[[")
|
||||
f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
|
||||
f.write(": : : invalid yaml [[[")
|
||||
f.close()
|
||||
path = Path(f.name)
|
||||
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||
await seed_idp_providers()
|
||||
async with async_session() as session:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""Tests for REST API endpoints and token auth."""
|
||||
|
||||
import hashlib
|
||||
|
||||
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
|
||||
from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token
|
||||
from wiregui.auth.passwords import hash_password
|
||||
from wiregui.models.api_token import ApiToken
|
||||
from wiregui.models.user import User
|
||||
|
|
@ -15,7 +13,7 @@ from wiregui.utils.time import utcnow
|
|||
def test_generate_api_token():
|
||||
plaintext, token_hash = generate_api_token()
|
||||
assert len(plaintext) > 20
|
||||
assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest()
|
||||
assert token_hash == _token_hmac(plaintext)
|
||||
|
||||
|
||||
def test_generate_api_token_unique():
|
||||
|
|
|
|||
70
uv.lock
generated
70
uv.lock
generated
|
|
@ -600,18 +600,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "fastapi"
|
||||
version = "0.135.2"
|
||||
|
|
@ -1502,6 +1490,20 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyopenssl"
|
||||
version = "26.0.0"
|
||||
|
|
@ -1599,25 +1601,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
|
|
@ -1797,18 +1780,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
|
|
@ -1830,15 +1801,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "smmap"
|
||||
version = "5.0.3"
|
||||
|
|
@ -2133,8 +2095,8 @@ dependencies = [
|
|||
{ name = "loguru" },
|
||||
{ name = "nicegui" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyotp" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python3-saml" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qrcode", extra = ["pil"] },
|
||||
|
|
@ -2165,8 +2127,8 @@ requires-dist = [
|
|||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "nicegui", specifier = ">=2.12" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.7" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
|
||||
{ name = "pyotp", specifier = ">=2.9" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
|
||||
{ name = "python3-saml", specifier = ">=1.16" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
|
|
|
|||
|
|
@ -1,27 +1,33 @@
|
|||
"""API token authentication — Bearer token via Authorization header."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from wiregui.config import get_settings
|
||||
from wiregui.models.api_token import ApiToken
|
||||
from wiregui.models.user import User
|
||||
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]:
|
||||
"""Generate a new API token. Returns (plaintext_token, token_hash)."""
|
||||
plaintext = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(plaintext.encode()).hexdigest()
|
||||
return plaintext, token_hash
|
||||
return plaintext, _token_hmac(plaintext)
|
||||
|
||||
|
||||
async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None:
|
||||
"""Look up a Bearer token and return the associated user, or None."""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_hash = _token_hmac(token)
|
||||
result = await session.execute(
|
||||
select(ApiToken).where(ApiToken.token_hash == token_hash)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
|
||||
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."""
|
||||
try:
|
||||
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ def _start_collector() -> None:
|
|||
global _collector_proc
|
||||
_collector_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "wiregui.collector"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("Metrics collector started (pid={})", _collector_proc.pid)
|
||||
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ async def oidc_callback(provider_id: str, request: Request):
|
|||
id_token = token.get("id_token")
|
||||
if id_token:
|
||||
try:
|
||||
from jose import jwt as jose_jwt
|
||||
import jwt as pyjwt
|
||||
# Decode without verification — we already verified during token exchange
|
||||
claims = jose_jwt.get_unverified_claims(id_token)
|
||||
claims = pyjwt.decode(id_token, options={"verify_signature": False})
|
||||
userinfo = userinfo or {}
|
||||
if not userinfo.get("email"):
|
||||
userinfo["email"] = claims.get("email")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue