Compare commits
4 Commits
1a807cfeb4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2502f005e3
|
|||
|
5aca0038b6
|
|||
|
24417bc7cf
|
|||
|
9c7f2bde11
|
+5
-1
@@ -131,4 +131,8 @@ dmypy.json
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Additional files from existing .gitignore
|
# Additional files from existing .gitignore
|
||||||
.envrc
|
.envrc
|
||||||
|
|
||||||
|
# Tailwind CSS generated artifacts
|
||||||
|
style.css
|
||||||
|
tailwindcss
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Compact operational guide for OpenCode sessions. See `CLAUDE.md` for the prose
|
||||||
|
overview; this file captures what an agent would otherwise get wrong.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First-time test env (creates venv/, installs requirements.txt + requirements-test.txt)
|
||||||
|
./setup_test_env.sh
|
||||||
|
|
||||||
|
# Full suite with coverage (activates venv, erases old coverage, builds htmlcov/)
|
||||||
|
./run_tests.sh
|
||||||
|
|
||||||
|
# Manual, from an active venv
|
||||||
|
python -m pytest tests/ --cov=. --cov-config=.coveragerc -v
|
||||||
|
|
||||||
|
# Single test
|
||||||
|
python -m pytest tests/test_main.py::test_health_check -v
|
||||||
|
|
||||||
|
# Dev server (must run from repo root — see Gotchas)
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no lint, typecheck, formatter, or CI config in this repo. Do not
|
||||||
|
claim to run `ruff`, `mypy`, `flake8`, or `black` — none are installed or
|
||||||
|
configured. The only verification step is the pytest suite above.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Run from repo root.** `read_root()` and `favicon()` open
|
||||||
|
`templates/index.html` / `templates/favicon.ico` via relative paths
|
||||||
|
(`main.py:329`, `main.py:371`). Invoking uvicorn from another cwd 404s the
|
||||||
|
frontend.
|
||||||
|
- **`@lru_cache` on OIDC helpers.** `get_well_known_config`, `get_jwks`,
|
||||||
|
`get_userinfo_endpoint`, `get_token_endpoint` are all `lru_cache(maxsize=1)`
|
||||||
|
(`main.py:68-129`). When a test patches `requests` with a different
|
||||||
|
response, call `func.cache_clear()` first or you get the previous mock's
|
||||||
|
cached value. Existing tests do this; new tests must too.
|
||||||
|
- **Python 3.14, not 3.7.** `.python-version` and the Dockerfile
|
||||||
|
(`python:3.14-alpine`) target 3.14. The README's "3.7+" claim is stale.
|
||||||
|
- **`.envrc` is gitignored and ships real credentials.** It exports a live
|
||||||
|
`CLIENT_ID` / `CLIENT_SECRET` for direnv. Never commit it, never paste its
|
||||||
|
values into code or commits.
|
||||||
|
- **`CLIENT_ID` has no default.** `main.py:28` reads it from env with no
|
||||||
|
fallback; the app and several tests need it set. `OIDC_ISSUER` defaults to
|
||||||
|
`https://login.infomaniak.com`; `CLIENT_SECRET` defaults to `""` (breaks
|
||||||
|
`/refresh-token` with "Client secret not configured").
|
||||||
|
|
||||||
|
## Architecture essentials
|
||||||
|
|
||||||
|
- Single-file backend `main.py` (FastAPI) + single-file frontend
|
||||||
|
`templates/index.html`. No package layout, no `pyproject.toml`, no
|
||||||
|
`setup.cfg`.
|
||||||
|
- `AUTHORIZED_USERS` (`main.py:33`) is a hardcoded `{email: secret_phrase}`
|
||||||
|
dict — the "protected resource". Not a database.
|
||||||
|
- Token validation: `verify_token(id_token, expected_nonce=None)` does JWKS
|
||||||
|
signature verification + audience/issuer checks. The `expected_nonce`
|
||||||
|
param binds the id_token to the login flow (anti-replay); `/validate-token`
|
||||||
|
forwards the nonce from the request body (`main.py:278`). Tests in
|
||||||
|
`test_token_validation.py` and `test_main.py` cover the nonce paths — keep
|
||||||
|
them green when touching auth.
|
||||||
|
- Tests use `fastapi.testclient.TestClient` (sync, backed by `httpx`) and
|
||||||
|
`unittest.mock.patch` against `main.*`. Fixtures live in `tests/conftest.py`.
|
||||||
|
- Coverage config (`.coveragerc`) omits `venv/`, `tests/`, `.venv/`.
|
||||||
+13
@@ -23,6 +23,18 @@ RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Build Tailwind CSS
|
||||||
|
COPY templates/index.html templates/index.html
|
||||||
|
COPY style-input.css tailwind.config.js ./
|
||||||
|
RUN case "$(uname -m)" in \
|
||||||
|
x86_64) arch="x64" ;; \
|
||||||
|
aarch64|arm64) arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -sL "https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-${arch}" -o tailwindcss \
|
||||||
|
&& chmod +x tailwindcss \
|
||||||
|
&& ./tailwindcss -i ./style-input.css -o ./style.css --minify
|
||||||
|
|
||||||
#############################################
|
#############################################
|
||||||
# Final stage
|
# Final stage
|
||||||
#############################################
|
#############################################
|
||||||
@@ -35,6 +47,7 @@ COPY --from=builder /usr/local/bin /usr/local/bin
|
|||||||
# Copy application files
|
# Copy application files
|
||||||
COPY main.py .
|
COPY main.py .
|
||||||
COPY templates/ templates/
|
COPY templates/ templates/
|
||||||
|
COPY --from=builder /app/style.css ./style.css
|
||||||
|
|
||||||
# Change ownership to non-root user
|
# Change ownership to non-root user
|
||||||
RUN chown -R app:app /app
|
RUN chown -R app:app /app
|
||||||
|
|||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build CSS using Tailwind CSS standalone CLI
|
||||||
|
# For local development, run:
|
||||||
|
# ./build-css.sh # one-off build
|
||||||
|
# ./build-css.sh --watch # watch mode
|
||||||
|
|
||||||
|
version="v3.4.17"
|
||||||
|
os="$(uname -s)"
|
||||||
|
arch="$(uname -m)"
|
||||||
|
|
||||||
|
case "$os" in
|
||||||
|
Linux*) platform="linux";;
|
||||||
|
Darwin*) platform="macos";;
|
||||||
|
*) echo "Unsupported OS: $os"; exit 1;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) arch="x64";;
|
||||||
|
arm64) arch="arm64";;
|
||||||
|
aarch64) arch="arm64";;
|
||||||
|
*) echo "Unsupported architecture: $arch"; exit 1;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
binary="tailwindcss-${platform}-${arch}"
|
||||||
|
url="https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${binary}"
|
||||||
|
|
||||||
|
if [ ! -f "tailwindcss" ]; then
|
||||||
|
echo "Downloading Tailwind CSS standalone CLI (${version} ${platform}/${arch})..."
|
||||||
|
curl -sL "$url" -o tailwindcss
|
||||||
|
chmod +x tailwindcss
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${1:-}" == "--watch" ]; then
|
||||||
|
echo "Building CSS in watch mode..."
|
||||||
|
./tailwindcss -i ./style-input.css -o ./style.css --watch
|
||||||
|
else
|
||||||
|
echo "Building CSS..."
|
||||||
|
./tailwindcss -i ./style-input.css -o ./style.css --minify
|
||||||
|
fi
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# XSS Sink Remediation + CSP Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Eliminate the `innerHTML` XSS sink in `utils.showAlert` and add a nonce-based Content-Security-Policy header to the frontend route.
|
||||||
|
|
||||||
|
**Architecture:** Frontend `showAlert` is rewritten to build alert DOM via `document.createElement` / `textContent` (no `innerHTML`). Backend `read_root()` generates a per-request nonce, injects it into the inline `<script>` tag, and sets a strict CSP header on the HTML response.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI (Python 3.14), vanilla JS frontend, pytest + fastapi.testclient.TestClient.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-08-xss-sink-csp-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Run all commands from repo root (relative path `templates/index.html` in `main.py:329`).
|
||||||
|
- Python 3.14 (`.python-version`, Dockerfile `python:3.14-alpine`).
|
||||||
|
- No lint/typecheck/formatter/CI — pytest is the only verification step.
|
||||||
|
- Test command: `python -m pytest tests/ --cov=. --cov-config=.coveragerc -v` (or `./run_tests.sh`).
|
||||||
|
- `@lru_cache` OIDC helpers (`main.py:68-129`) — call `func.cache_clear()` when patching `requests`. Not relevant to this plan but good to know.
|
||||||
|
- `CLIENT_ID` has no default (`main.py:28`) — the test env sets it via `run_tests.sh`; the CSP test does not depend on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add CSP header + nonce injection to `read_root()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.py:1-10` (add `import secrets`)
|
||||||
|
- Modify: `main.py:326-331` (`read_root` function)
|
||||||
|
- Test: `tests/test_main.py` (add new test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: none
|
||||||
|
- Produces: `GET /` now returns a `Content-Security-Policy` response header containing a fresh per-request nonce, and the HTML body's inline `<script>` tag carries a matching `nonce="..."` attribute.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add this test to `tests/test_main.py` (after `test_get_client_config`, around line 33):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_root_serves_csp_with_nonce():
|
||||||
|
"""Test that GET / returns a CSP header with a nonce and the inline script carries it."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
csp = response.headers.get("content-security-policy", "")
|
||||||
|
assert "nonce-" in csp
|
||||||
|
assert "script-src" in csp
|
||||||
|
assert "cdn.jsdelivr.net" in csp
|
||||||
|
assert "connect-src" in csp
|
||||||
|
assert "login.infomaniak.com" in csp
|
||||||
|
assert "api.coingecko.com" in csp
|
||||||
|
assert "object-src 'none'" in csp
|
||||||
|
body = response.text
|
||||||
|
assert "<script nonce=" in body
|
||||||
|
import re
|
||||||
|
match = re.search(r"nonce-([A-Za-z0-9_-]+)", csp)
|
||||||
|
assert match is not None
|
||||||
|
nonce = match.group(1)
|
||||||
|
assert f'nonce="{nonce}"' in body
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_main.py::test_root_serves_csp_with_nonce -v`
|
||||||
|
Expected: FAIL — `assert "nonce-" in csp` fails because no CSP header is set yet (empty string).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `import secrets` to `main.py`**
|
||||||
|
|
||||||
|
In `main.py`, add `import secrets` after `import os` (line 9), keeping it in the stdlib group:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from functools import lru_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
(Line 10's `from functools import lru_cache` stays where it is — just insert `import secrets` between lines 9 and 10.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite `read_root()` with nonce injection + CSP header**
|
||||||
|
|
||||||
|
Replace the body of `read_root()` at `main.py:326-331` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def read_root():
|
||||||
|
"""Serve the frontend HTML file"""
|
||||||
|
nonce = secrets.token_urlsafe(16)
|
||||||
|
with open("templates/index.html", "r") as file:
|
||||||
|
html_content = file.read()
|
||||||
|
html_content = html_content.replace(
|
||||||
|
'<script>', f'<script nonce="{nonce}">', 1
|
||||||
|
)
|
||||||
|
csp = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
||||||
|
"style-src 'self' https://cdn.jsdelivr.net; "
|
||||||
|
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
|
||||||
|
"img-src 'self'; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=html_content,
|
||||||
|
status_code=200,
|
||||||
|
headers={"Content-Security-Policy": csp},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `replace('<script>', ..., 1)` targets only the bare inline tag at `templates/index.html:62`. The Bootstrap tag at line 8 is `<script src=...>` (does not match the literal `<script>`) and is authorized by the `https://cdn.jsdelivr.net` entry in `script-src`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_main.py::test_root_serves_csp_with_nonce -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run full suite to confirm no regressions**
|
||||||
|
|
||||||
|
Run: `./run_tests.sh`
|
||||||
|
Expected: All tests pass (including the new one). Coverage report builds in `htmlcov/`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add main.py tests/test_main.py
|
||||||
|
git commit -m "fix(security): add nonce-based CSP header to frontend route"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Remove the `innerHTML` XSS sink in `showAlert`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `templates/index.html:162-164` (`showAlert` method)
|
||||||
|
- Modify: `templates/index.html:468` (caller — cached secret)
|
||||||
|
- Modify: `templates/index.html:584` (caller — server secret phrase)
|
||||||
|
- Modify: `templates/index.html:619-623` (caller — BTC price)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: none
|
||||||
|
- Produces: `utils.showAlert(element, message, type)` now accepts either a plain `string` (rendered as `textContent`) or a structured `{title, body}` object (title rendered in `<strong>` as `textContent`, body rendered as text). The `innerHTML` sink is gone.
|
||||||
|
|
||||||
|
**Note on testing:** This repo has no JavaScript test framework. This task is verified by (a) the Python test from Task 1 confirming the page still serves correctly, and (b) manual browser verification steps at the end. The CSP from Task 1 provides defense-in-depth: even if a future caller passes attacker-controlled data, inline script execution is blocked.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite `showAlert` to build DOM instead of using `innerHTML`**
|
||||||
|
|
||||||
|
In `templates/index.html`, replace the `showAlert` method (lines 162-164):
|
||||||
|
|
||||||
|
Current:
|
||||||
|
```js
|
||||||
|
showAlert(element, message, type = 'info') {
|
||||||
|
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```js
|
||||||
|
showAlert(element, message, type = 'info') {
|
||||||
|
element.replaceChildren();
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert alert-${type}`;
|
||||||
|
alert.setAttribute('role', 'alert');
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
alert.textContent = message;
|
||||||
|
} else if (message && typeof message === 'object') {
|
||||||
|
if (message.title) {
|
||||||
|
const strong = document.createElement('strong');
|
||||||
|
strong.textContent = message.title;
|
||||||
|
alert.appendChild(strong);
|
||||||
|
}
|
||||||
|
if (message.body) {
|
||||||
|
if (message.title) {
|
||||||
|
alert.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
alert.appendChild(document.createTextNode(message.body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.appendChild(alert);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update caller at line 468 (cached secret phrase)**
|
||||||
|
|
||||||
|
Current (`templates/index.html:468`):
|
||||||
|
```js
|
||||||
|
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```js
|
||||||
|
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: cachedSecret }, 'success');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update caller at line 584 (server secret phrase)**
|
||||||
|
|
||||||
|
Current (`templates/index.html:584`):
|
||||||
|
```js
|
||||||
|
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```js
|
||||||
|
utils.showAlert(elements.secretResult, { title: 'Secret Phrase:', body: data.secret_phrase }, 'success');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update caller at lines 619-623 (BTC price)**
|
||||||
|
|
||||||
|
Current (`templates/index.html:619-623`):
|
||||||
|
```js
|
||||||
|
utils.showAlert(
|
||||||
|
elements.btcPriceResult,
|
||||||
|
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```js
|
||||||
|
utils.showAlert(
|
||||||
|
elements.btcPriceResult,
|
||||||
|
{ title: `1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify no `innerHTML` remains in `showAlert` or its callers**
|
||||||
|
|
||||||
|
Run: `grep -n 'innerHTML' templates/index.html`
|
||||||
|
Expected: no matches in the `showAlert` method or its callers. (If other unrelated `innerHTML` uses exist elsewhere in the file, they are out of scope for this task — leave them.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run full Python suite to confirm the page still serves**
|
||||||
|
|
||||||
|
Run: `./run_tests.sh`
|
||||||
|
Expected: All tests pass, including `test_root_serves_csp_with_nonce` from Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual browser verification**
|
||||||
|
|
||||||
|
Start the dev server: `uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
|
||||||
|
|
||||||
|
Open `http://localhost:8000` and verify:
|
||||||
|
- The page loads with no browser console errors about CSP violations.
|
||||||
|
- The Bootstrap styling still renders (CSS from cdn.jsdelivr.net loads).
|
||||||
|
- Click "Fetch Bitcoin Price" — the success alert shows **1 Bitcoin (BTC) = CHF ...** in bold.
|
||||||
|
- (If you have OIDC credentials) Log in, click "Refresh" on the Secret Phrase card — the success alert shows **Secret Phrase:** in bold followed by the phrase text.
|
||||||
|
- Trigger an error (e.g., click "Validate Token" with no token) — the error alert renders the message as plain text.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add templates/index.html
|
||||||
|
git commit -m "fix(security): remove innerHTML XSS sink in showAlert"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Task | What | Verification |
|
||||||
|
|------|------|--------------|
|
||||||
|
| 1 | CSP header + nonce injection in `read_root()` | pytest (automated) |
|
||||||
|
| 2 | `showAlert` DOM rewrite + 3 caller updates | grep + manual browser check |
|
||||||
|
|
||||||
|
After both tasks, the finding is resolved: the `innerHTML` sink is structurally gone, and a strict nonce-based CSP blocks inline script execution as defense-in-depth.
|
||||||
@@ -335,8 +335,8 @@ async def read_root():
|
|||||||
)
|
)
|
||||||
csp = (
|
csp = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
f"script-src 'self' 'nonce-{nonce}'; "
|
||||||
"style-src 'self' https://cdn.jsdelivr.net; "
|
"style-src 'self'; "
|
||||||
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
|
"connect-src 'self' https://login.infomaniak.com https://api.coingecko.com; "
|
||||||
"img-src 'self'; "
|
"img-src 'self'; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
@@ -389,6 +389,11 @@ async def favicon():
|
|||||||
"""Serve the favicon"""
|
"""Serve the favicon"""
|
||||||
return FileResponse("templates/favicon.ico")
|
return FileResponse("templates/favicon.ico")
|
||||||
|
|
||||||
|
@app.get("/style.css")
|
||||||
|
async def stylesheet():
|
||||||
|
"""Serve the Tailwind-generated stylesheet"""
|
||||||
|
return FileResponse("style.css", media_type="text/css")
|
||||||
|
|
||||||
@app.get("/config", response_model=ClientConfig)
|
@app.get("/config", response_model=ClientConfig)
|
||||||
async def get_client_config():
|
async def get_client_config():
|
||||||
"""Serve client configuration to frontend"""
|
"""Serve client configuration to frontend"""
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./templates/index.html"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
+35
-30
@@ -4,54 +4,53 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OIDC Login</title>
|
<title>OIDC Login</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container mt-5">
|
<div class="container mx-auto mt-5 px-4">
|
||||||
<div class="row justify-content-center">
|
<div class="flex justify-center">
|
||||||
<div class="col-md-6">
|
<div class="w-full md:w-1/2">
|
||||||
<div class="card">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<div class="card-header">
|
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
<h1 class="text-center h3">OIDC Login</h1>
|
<h1 class="text-center text-2xl font-bold">OIDC Login</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-4">
|
||||||
<!-- Login Section -->
|
<!-- Login Section -->
|
||||||
<section id="login-section" class="text-center">
|
<section id="login-section" class="text-center">
|
||||||
<p>Login with your Infomaniak account</p>
|
<p>Login with your Infomaniak account</p>
|
||||||
<button id="login-btn" class="btn btn-primary w-100" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
<button id="login-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- User Info Section -->
|
<!-- User Info Section -->
|
||||||
<section id="user-info" class="d-none">
|
<section id="user-info" class="hidden">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="h5">Welcome, <span id="user-name">User</span>!</h2>
|
<h2 class="text-xl font-bold">Welcome, <span id="user-name">User</span>!</h2>
|
||||||
<p class="mb-0">Email: <span id="user-email"></span></p>
|
<p class="mb-0">Email: <span id="user-email"></span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secret Phrase Card -->
|
<!-- Secret Phrase Card -->
|
||||||
<div class="card mb-4">
|
<div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||||
<h3 class="h5 mb-0">Secret Phrase</h3>
|
<h3 class="text-xl font-bold mb-0">Secret Phrase</h3>
|
||||||
<button id="refresh-secret-btn" class="btn btn-secondary btn-sm" aria-label="Refresh secret phrase">Refresh</button>
|
<button id="refresh-secret-btn" class="bg-gray-500 hover:bg-gray-600 text-white text-sm py-1 px-2 rounded transition" aria-label="Refresh secret phrase">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-4">
|
||||||
<div id="secret-result" class="mb-0">Checking for secret phrase...</div>
|
<div id="secret-result" class="mb-0">Checking for secret phrase...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bitcoin Price Card -->
|
<!-- Bitcoin Price Card -->
|
||||||
<div class="card mb-4">
|
<div class="bg-white rounded-lg shadow-md mb-4 overflow-hidden">
|
||||||
<div class="card-header">
|
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
<h3 class="h5 mb-0">Bitcoin Price in CHF</h3>
|
<h3 class="text-xl font-bold mb-0">Bitcoin Price in CHF</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-4">
|
||||||
<button id="fetch-btc-btn" class="btn btn-success mb-3" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
<button id="fetch-btc-btn" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded mb-3 transition" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
||||||
<div id="btc-price-result" class="mb-0"></div>
|
<div id="btc-price-result" class="mb-0"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="logout-btn" class="btn btn-danger w-100" aria-label="Logout">Logout</button>
|
<button id="logout-btn" class="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded transition" aria-label="Logout">Logout</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,12 +156,18 @@
|
|||||||
* Show an alert message in the specified element
|
* Show an alert message in the specified element
|
||||||
* @param {HTMLElement} element - Element to show the message in
|
* @param {HTMLElement} element - Element to show the message in
|
||||||
* @param {string} message - Message to display
|
* @param {string} message - Message to display
|
||||||
* @param {string} type - Bootstrap alert type (success, danger, warning, info)
|
* @param {string} type - Alert type (success, danger, warning, info)
|
||||||
*/
|
*/
|
||||||
showAlert(element, message, type = 'info') {
|
showAlert(element, message, type = 'info') {
|
||||||
|
const alertClasses = {
|
||||||
|
success: 'bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded',
|
||||||
|
danger: 'bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded',
|
||||||
|
warning: 'bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded',
|
||||||
|
info: 'bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded'
|
||||||
|
};
|
||||||
element.replaceChildren();
|
element.replaceChildren();
|
||||||
const alert = document.createElement('div');
|
const alert = document.createElement('div');
|
||||||
alert.className = `alert alert-${type}`;
|
alert.className = alertClasses[type] || alertClasses.info;
|
||||||
alert.setAttribute('role', 'alert');
|
alert.setAttribute('role', 'alert');
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
alert.textContent = message;
|
alert.textContent = message;
|
||||||
@@ -427,8 +432,8 @@
|
|||||||
* @param {Object} user - User object with email, first_name, last_name
|
* @param {Object} user - User object with email, first_name, last_name
|
||||||
*/
|
*/
|
||||||
showUserInfo(user) {
|
showUserInfo(user) {
|
||||||
elements.loginSection.classList.add('d-none');
|
elements.loginSection.classList.add('hidden');
|
||||||
elements.userInfo.classList.remove('d-none');
|
elements.userInfo.classList.remove('hidden');
|
||||||
|
|
||||||
// Display user's full name
|
// Display user's full name
|
||||||
elements.userName.textContent = utils.getUserDisplayName(user);
|
elements.userName.textContent = utils.getUserDisplayName(user);
|
||||||
@@ -441,8 +446,8 @@
|
|||||||
* Hide user information section
|
* Hide user information section
|
||||||
*/
|
*/
|
||||||
hideUserInfo() {
|
hideUserInfo() {
|
||||||
elements.loginSection.classList.remove('d-none');
|
elements.loginSection.classList.remove('hidden');
|
||||||
elements.userInfo.classList.add('d-none');
|
elements.userInfo.classList.add('hidden');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -699,4 +704,4 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ def test_root_serves_csp_with_nonce():
|
|||||||
csp = response.headers.get("content-security-policy", "")
|
csp = response.headers.get("content-security-policy", "")
|
||||||
assert "nonce-" in csp
|
assert "nonce-" in csp
|
||||||
assert "script-src" in csp
|
assert "script-src" in csp
|
||||||
assert "cdn.jsdelivr.net" in csp
|
assert "cdn.jsdelivr.net" not in csp
|
||||||
assert "connect-src" in csp
|
assert "connect-src" in csp
|
||||||
assert "login.infomaniak.com" in csp
|
assert "login.infomaniak.com" in csp
|
||||||
assert "api.coingecko.com" in csp
|
assert "api.coingecko.com" in csp
|
||||||
|
|||||||
Reference in New Issue
Block a user