From 4daf382819f87145cb00092958729814b0d85b5d Mon Sep 17 00:00:00 2001
From: AlexanderWhitestone
<8633216+AlexanderWhitestone@users.noreply.github.com>
Date: Tue, 24 Feb 2026 12:58:19 -0500
Subject: [PATCH] security: fix L402 macaroon forgery and XSS in templates
---
apply_security_fixes.py | 183 ++
coverage.xml | 2222 +++++++++++++++++
src/dashboard/templates/briefing.html | 2 +-
src/dashboard/templates/marketplace.html | 8 +-
.../partials/approval_card_single.html | 6 +-
.../templates/partials/chat_message.html | 6 +-
src/dashboard/templates/partials/history.html | 6 +-
src/timmy_serve/l402_proxy.py | 38 +-
tests/test_security_regression.py | 75 +
9 files changed, 2522 insertions(+), 24 deletions(-)
create mode 100644 apply_security_fixes.py
create mode 100644 coverage.xml
create mode 100644 tests/test_security_regression.py
diff --git a/apply_security_fixes.py b/apply_security_fixes.py
new file mode 100644
index 0000000..2f4420c
--- /dev/null
+++ b/apply_security_fixes.py
@@ -0,0 +1,183 @@
+import os
+
+def fix_l402_proxy():
+ path = "src/timmy_serve/l402_proxy.py"
+ with open(path, "r") as f:
+ content = f.read()
+
+ # 1. Add hmac_secret to Macaroon dataclass
+ old_dataclass = "@dataclass\nclass Macaroon:\n \"\"\"Simplified HMAC-based macaroon for L402 authentication.\"\"\"\n identifier: str # payment_hash\n signature: str # HMAC signature\n location: str = \"timmy-time\"\n version: int = 1"
+ new_dataclass = "@dataclass\nclass Macaroon:\n \"\"\"Simplified HMAC-based macaroon for L402 authentication.\"\"\"\n identifier: str # payment_hash\n signature: str # HMAC signature\n location: str = \"timmy-time\"\n version: int = 1\n hmac_secret: str = \"\" # Added for multi-key support"
+ content = content.replace(old_dataclass, new_dataclass)
+
+ # 2. Update _MACAROON_SECRET logic
+ old_secret_logic = """_MACAROON_SECRET_DEFAULT = "timmy-macaroon-secret"
+_MACAROON_SECRET_RAW = os.environ.get("L402_MACAROON_SECRET", _MACAROON_SECRET_DEFAULT)
+_MACAROON_SECRET = _MACAROON_SECRET_RAW.encode()
+
+if _MACAROON_SECRET_RAW == _MACAROON_SECRET_DEFAULT:
+ logger.warning(
+ "SEC: L402_MACAROON_SECRET is using the default value — set a unique "
+ "secret in .env before deploying to production."
+ )"""
+ new_secret_logic = """_MACAROON_SECRET_DEFAULT = "timmy-macaroon-secret"
+_MACAROON_SECRET_RAW = os.environ.get("L402_MACAROON_SECRET", _MACAROON_SECRET_DEFAULT)
+_MACAROON_SECRET = _MACAROON_SECRET_RAW.encode()
+
+_HMAC_SECRET_DEFAULT = "timmy-hmac-secret"
+_HMAC_SECRET_RAW = os.environ.get("L402_HMAC_SECRET", _HMAC_SECRET_DEFAULT)
+_HMAC_SECRET = _HMAC_SECRET_RAW.encode()
+
+if _MACAROON_SECRET_RAW == _MACAROON_SECRET_DEFAULT or _HMAC_SECRET_RAW == _HMAC_SECRET_DEFAULT:
+ logger.warning(
+ "SEC: L402 secrets are using default values — set L402_MACAROON_SECRET "
+ "and L402_HMAC_SECRET in .env before deploying to production."
+ )"""
+ content = content.replace(old_secret_logic, new_secret_logic)
+
+ # 3. Update _sign to use the two-key derivation
+ old_sign = """def _sign(identifier: str) -> str:
+ \"\"\"Create an HMAC signature for a macaroon identifier.\"\"\"
+ return hmac.new(_MACAROON_SECRET, identifier.encode(), hashlib.sha256).hexdigest()"""
+ new_sign = """def _sign(identifier: str, hmac_secret: Optional[str] = None) -> str:
+ \"\"\"Create an HMAC signature for a macaroon identifier using two-key derivation.
+
+ The base macaroon secret is used to derive a key-specific secret from the
+ hmac_secret, which is then used to sign the identifier. This prevents
+ macaroon forgery if the hmac_secret is known but the base secret is not.
+ \"\"\"
+ key = hmac.new(
+ _MACAROON_SECRET,
+ (hmac_secret or _HMAC_SECRET_RAW).encode(),
+ hashlib.sha256
+ ).digest()
+ return hmac.new(key, identifier.encode(), hashlib.sha256).hexdigest()"""
+ content = content.replace(old_sign, new_sign)
+
+ # 4. Update create_l402_challenge
+ old_create = """ invoice = payment_handler.create_invoice(amount_sats, memo)
+ signature = _sign(invoice.payment_hash)
+ macaroon = Macaroon(
+ identifier=invoice.payment_hash,
+ signature=signature,
+ )"""
+ new_create = """ invoice = payment_handler.create_invoice(amount_sats, memo)
+ hmac_secret = _HMAC_SECRET_RAW
+ signature = _sign(invoice.payment_hash, hmac_secret)
+ macaroon = Macaroon(
+ identifier=invoice.payment_hash,
+ signature=signature,
+ hmac_secret=hmac_secret,
+ )"""
+ content = content.replace(old_create, new_create)
+
+ # 5. Update Macaroon.serialize and deserialize
+ old_serialize = """ def serialize(self) -> str:
+ \"\"\"Encode the macaroon as a base64 string.\"\"\"
+ raw = f"{self.version}:{self.location}:{self.identifier}:{self.signature}"
+ return base64.urlsafe_b64encode(raw.encode()).decode()"""
+ new_serialize = """ def serialize(self) -> str:
+ \"\"\"Encode the macaroon as a base64 string.\"\"\"
+ raw = f"{self.version}:{self.location}:{self.identifier}:{self.signature}:{self.hmac_secret}"
+ return base64.urlsafe_b64encode(raw.encode()).decode()"""
+ content = content.replace(old_serialize, new_serialize)
+
+ old_deserialize = """ @classmethod
+ def deserialize(cls, token: str) -> Optional["Macaroon"]:
+ \"\"\"Decode a base64 macaroon string.\"\"\"
+ try:
+ raw = base64.urlsafe_b64decode(token.encode()).decode()
+ parts = raw.split(":")
+ if len(parts) != 4:
+ return None
+ return cls(
+ version=int(parts[0]),
+ location=parts[1],
+ identifier=parts[2],
+ signature=parts[3],
+ )
+ except Exception:
+ return None"""
+ new_deserialize = """ @classmethod
+ def deserialize(cls, token: str) -> Optional["Macaroon"]:
+ \"\"\"Decode a base64 macaroon string.\"\"\"
+ try:
+ raw = base64.urlsafe_b64decode(token.encode()).decode()
+ parts = raw.split(":")
+ if len(parts) < 4:
+ return None
+ return cls(
+ version=int(parts[0]),
+ location=parts[1],
+ identifier=parts[2],
+ signature=parts[3],
+ hmac_secret=parts[4] if len(parts) > 4 else "",
+ )
+ except Exception:
+ return None"""
+ content = content.replace(old_deserialize, new_deserialize)
+
+ # 6. Update verify_l402_token
+ old_verify_sig = """ # Check HMAC signature
+ expected_sig = _sign(macaroon.identifier)
+ if not hmac.compare_digest(macaroon.signature, expected_sig):"""
+ new_verify_sig = """ # Check HMAC signature
+ expected_sig = _sign(macaroon.identifier, macaroon.hmac_secret)
+ if not hmac.compare_digest(macaroon.signature, expected_sig):"""
+ content = content.replace(old_verify_sig, new_verify_sig)
+
+ with open(path, "w") as f:
+ f.write(content)
+
+def fix_xss():
+ # Fix chat_message.html
+ path = "src/dashboard/templates/partials/chat_message.html"
+ with open(path, "r") as f:
+ content = f.read()
+ content = content.replace("{{ user_message }}", "{{ user_message | e }}")
+ content = content.replace("{{ response }}", "{{ response | e }}")
+ content = content.replace("{{ error }}", "{{ error | e }}")
+ with open(path, "w") as f:
+ f.write(content)
+
+ # Fix history.html
+ path = "src/dashboard/templates/partials/history.html"
+ with open(path, "r") as f:
+ content = f.read()
+ content = content.replace("{{ msg.content }}", "{{ msg.content | e }}")
+ with open(path, "w") as f:
+ f.write(content)
+
+ # Fix briefing.html
+ path = "src/dashboard/templates/briefing.html"
+ with open(path, "r") as f:
+ content = f.read()
+ content = content.replace("{{ briefing.summary }}", "{{ briefing.summary | e }}")
+ with open(path, "w") as f:
+ f.write(content)
+
+ # Fix approval_card_single.html
+ path = "src/dashboard/templates/partials/approval_card_single.html"
+ with open(path, "r") as f:
+ content = f.read()
+ content = content.replace("{{ item.title }}", "{{ item.title | e }}")
+ content = content.replace("{{ item.description }}", "{{ item.description | e }}")
+ content = content.replace("{{ item.proposed_action }}", "{{ item.proposed_action | e }}")
+ with open(path, "w") as f:
+ f.write(content)
+
+ # Fix marketplace.html
+ path = "src/dashboard/templates/marketplace.html"
+ with open(path, "r") as f:
+ content = f.read()
+ content = content.replace("{{ agent.name }}", "{{ agent.name | e }}")
+ content = content.replace("{{ agent.role }}", "{{ agent.role | e }}")
+ content = content.replace("{{ agent.description or 'No description' }}", "{{ (agent.description or 'No description') | e }}")
+ content = content.replace("{{ cap.strip() }}", "{{ cap.strip() | e }}")
+ with open(path, "w") as f:
+ f.write(content)
+
+if __name__ == "__main__":
+ fix_l402_proxy()
+ fix_xss()
+ print("Security fixes applied successfully.")
diff --git a/coverage.xml b/coverage.xml
new file mode 100644
index 0000000..6a8d2dd
--- /dev/null
+++ b/coverage.xml
@@ -0,0 +1,2222 @@
+
+