Skip to content

Commit b57731f

Browse files
committed
Harden VAPID private key parsing and validate at startup
1 parent a34c1dc commit b57731f

1 file changed

Lines changed: 39 additions & 6 deletions

File tree

server/push_notifications.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import aiohttp_session
2020
from aiohttp import web
21+
from cryptography.hazmat.primitives import serialization
2122
from tenacity import (
2223
AsyncRetrying,
2324
before_sleep_log,
@@ -71,16 +72,39 @@ def __init__(self, message: str, *, status_code: int | None = None) -> None:
7172

7273

7374
class PushNotifier:
75+
@staticmethod
76+
def _strip_wrapping_quotes(value: str) -> str:
77+
if len(value) >= 2 and (
78+
(value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'")
79+
):
80+
return value[1:-1]
81+
return value
82+
83+
@classmethod
84+
def _normalize_vapid_private_key(cls, raw_value: str) -> str:
85+
key = cls._strip_wrapping_quotes(raw_value.strip())
86+
# Accept env values that store PEM newlines as escaped sequences.
87+
if "\\n" in key:
88+
key = key.replace("\\n", "\n")
89+
return key.strip()
90+
91+
@staticmethod
92+
def _validate_vapid_private_key(private_key: str) -> tuple[bool, str | None]:
93+
try:
94+
serialization.load_pem_private_key(private_key.encode("utf-8"), password=None)
95+
return (True, None)
96+
except Exception as exc:
97+
return (False, f"{type(exc).__name__}: {exc}")
98+
7499
def __init__(self, app_state):
75100
self.app_state = app_state
76101
self.queue: asyncio.Queue[CorrMovePushJob] = asyncio.Queue(maxsize=PUSH_QUEUE_MAXSIZE)
77102

78-
private_key = PUSH_VAPID_PRIVATE_KEY.strip()
79-
if "\\n" in private_key:
80-
private_key = private_key.replace("\\n", "\n")
103+
private_key = self._normalize_vapid_private_key(PUSH_VAPID_PRIVATE_KEY)
104+
vapid_public_key = self._strip_wrapping_quotes(PUSH_VAPID_PUBLIC_KEY.strip())
81105

82106
self.vapid_private_key = private_key
83-
self.vapid_public_key = PUSH_VAPID_PUBLIC_KEY.strip()
107+
self.vapid_public_key = vapid_public_key
84108
self.vapid_claims: dict[str, str | int] = {"sub": PUSH_VAPID_SUBJECT}
85109
self.retry_attempts = PUSH_RETRY_MAX_ATTEMPTS
86110
self.retry_wait_initial_seconds = PUSH_RETRY_WAIT_INITIAL_SECONDS
@@ -101,6 +125,16 @@ def __init__(self, app_state):
101125
log.info("Web push disabled: PUSH_VAPID_PUBLIC_KEY is not configured")
102126
elif not self.vapid_private_key:
103127
log.info("Web push disabled: PUSH_VAPID_PRIVATE_KEY is not configured")
128+
else:
129+
private_key_ok, private_key_error = self._validate_vapid_private_key(
130+
self.vapid_private_key
131+
)
132+
if not private_key_ok:
133+
self.enabled = False
134+
log.error(
135+
"Web push disabled: invalid PUSH_VAPID_PRIVATE_KEY format (%s)",
136+
private_key_error,
137+
)
104138

105139
def enqueue_corr_move(self, user: User, game_id: str, opponent: str, san: str) -> None:
106140
"""Queue a corr-move notification when the user is eligible."""
@@ -317,8 +351,7 @@ async def _send_with_retry(self, subscription_info: dict, payload: str) -> None:
317351
raise
318352
except Exception as exc:
319353
raise PushSendRetryableError(
320-
"Retryable push delivery failure: "
321-
f"{type(exc).__name__}: {exc}"
354+
f"Retryable push delivery failure: {type(exc).__name__}: {exc}"
322355
) from exc
323356

324357

0 commit comments

Comments
 (0)