Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab459df
hotfix: add fail-closed dashboard auth middleware
Namnarak Sep 7, 2026
c8a4a64
hotfix: install dashboard security middleware
Namnarak Sep 7, 2026
3a97cd0
hotfix: attach Discord bearer token to dashboard API calls
Namnarak Sep 7, 2026
15a91fd
hotfix: inject dashboard auth bootstrap at Cloudflare edge
Namnarak Sep 7, 2026
98f4756
hotfix: remove hardcoded backend and verify checkout identity
Namnarak Sep 7, 2026
33a35d9
hotfix: disable unauthenticated realtime gateway
Namnarak Sep 7, 2026
0965eed
hotfix: make recommendation backend deployer-configured
Namnarak Sep 7, 2026
db18f9b
hotfix: remove Cyori identity defaults from Community config
Namnarak Sep 7, 2026
edb3cb5
hotfix: load Community runtime client identity
Namnarak Sep 7, 2026
2ac73ec
hotfix: expose safe Community runtime client ID
Namnarak Sep 7, 2026
cbe37ae
hotfix: expose runtime identity before dashboard auth
Namnarak Sep 7, 2026
d93e908
hotfix: serve deployer-configured Community client ID
Namnarak Sep 7, 2026
24f5f90
hotfix: remove Cyori OAuth defaults and auto-join
Namnarak Sep 7, 2026
690c011
hotfix: prevent empty Mongo selectors from mutating user documents
Namnarak Sep 7, 2026
f709beb
hotfix: remove Cyori invite and support-server auto-join
Namnarak Sep 7, 2026
7157a1b
hotfix: prevent stale Cyori links from escaping Community deployment
Namnarak Sep 7, 2026
30cbb6d
hotfix: force empty Mongo reads to resolved root document
Namnarak Sep 7, 2026
c5529c4
hotfix: enforce Manage Guild on DJ and vote settings
Namnarak Sep 7, 2026
32b356b
hotfix: install sensitive command permission guard
Namnarak Sep 7, 2026
7975717
ci: add JavaScript syntax checks
Namnarak Sep 7, 2026
041da9f
hotfix: return only mutual guilds to authenticated users
Namnarak Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- "revival/**"
- "hotfix/**"
pull_request:

permissions:
Expand Down Expand Up @@ -41,6 +42,32 @@ jobs:
- name: Compile Python sources
run: python -m compileall -q bot.py main.py cogs core cytechlink utils web

web-syntax:
name: JavaScript syntax
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Check browser scripts
run: |
node --check web/script.js
node --check web/settings.js
node --check web/security-bootstrap.js

- name: Check Cloudflare Pages modules
run: |
node --input-type=module --check < web/functions/_middleware.js
node --input-type=module --check < web/functions/api/proxy.js
node --input-type=module --check < web/functions/api/gateway.js
node --input-type=module --check < web/functions/api/recommended.js

security-audit:
name: Dependency audit
runs-on: ubuntu-latest
Expand Down
44 changes: 39 additions & 5 deletions core/database.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import asyncio
import time
from typing import Dict, Any, Optional, Union
from typing import Dict, Any, Union
from motor.motor_asyncio import AsyncIOMotorCollection


class _RootSafeCollectionProxy:
"""Delegate collection access while making empty root selectors fail-safe.

This collection stores both the root config document and standalone user
documents. Accidental find_one({}) / update_one({}) calls can otherwise
select an arbitrary user document. Empty selectors are rewritten to the
resolved root _id.
"""

def __init__(self, manager: "DatabaseManager", collection: AsyncIOMotorCollection):
self._manager = manager
self._collection = collection

def __getattr__(self, name):
return getattr(self._collection, name)

async def find_one(self, filter=None, *args, **kwargs):
if filter == {}:
await self._manager._fetch_root()
if self._manager._root_id is None:
return None
filter = {"_id": self._manager._root_id}
return await self._collection.find_one(filter, *args, **kwargs)

async def update_one(self, filter, update, *args, **kwargs):
if filter == {}:
await self._manager._fetch_root()
if self._manager._root_id is None:
raise RuntimeError("Refusing update_one({}): root document could not be resolved")
filter = {"_id": self._manager._root_id}
return await self._collection.update_one(filter, update, *args, **kwargs)


class DatabaseManager:
"""
Centralized Database Manager with In-Memory Caching.
Reduces the number of redundant MongoDB query calls.
"""

def __init__(self, collection: AsyncIOMotorCollection):
self.collection = collection
self._raw_collection = collection
self._cache_data: Dict[str, Any] = {}
self._last_fetch_time: float = 0
self._cache_ttl: int = 60 # Cache duration (seconds)
self._lock = asyncio.Lock()
self._root_id = None
self.collection = _RootSafeCollectionProxy(self, collection)

async def _fetch_root(self, force: bool = False) -> Dict[str, Any]:
"""Fetch the root document (often empty query `{}`) and cache it."""
"""Fetch the root document and cache it."""
current_time = time.time()
# Return cache if valid
if (
Expand All @@ -39,7 +73,7 @@ async def _fetch_root(self, force: bool = False) -> Dict[str, Any]:
return self._cache_data

try:
# 🛡️ Protection: Exclude user docs strictly, find the actual root config doc
# Protection: Exclude user docs strictly, find the actual root config doc
data = await self.collection.find_one(
{"user_id": {"$exists": False}, "guilds": {"$exists": True}}
)
Expand Down Expand Up @@ -102,7 +136,7 @@ async def update_guild(
set_payload = {f"guilds.{gid}.{k}": v for k, v in update_dict.items()}

try:
# 🛡️ Protection: Only update the explicitly cached root document
# Protection: Only update the explicitly cached root document
query = (
{"_id": self._root_id}
if self._root_id
Expand Down
13 changes: 12 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from discord.ext import commands
from bot import Cyori
from utils.config import BOT_TOKEN
from utils.dashboard_security import make_dashboard_security_middleware
from utils.runtime_config import make_runtime_config_middleware
from utils.command_security import install_settings_permission_guard

logging.basicConfig(level=logging.INFO)
logging.getLogger("discord.http").setLevel(logging.CRITICAL)
Expand All @@ -19,11 +22,19 @@ async def get_prefix(bot, message: discord.Message):

return commands.when_mentioned_or(prefix)(bot, message)

class SecuredCyori(Cyori):
async def setup_hook(self):
await super().setup_hook()
install_settings_permission_guard(self)
if self.web_app is not None:
self.web_app.middlewares.append(make_dashboard_security_middleware(self))
self.web_app.middlewares.append(make_runtime_config_middleware(self))

intents = discord.Intents.default()
intents.members = True
intents.message_content = True

bot = Cyori(
bot = SecuredCyori(
command_prefix=get_prefix,
activity=discord.Activity(type=discord.ActivityType.listening, name="Starting..."),
help_command=None,
Expand Down
67 changes: 67 additions & 0 deletions utils/command_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Defense-in-depth permission guards for sensitive hybrid settings commands."""
import discord


RESTRICTED_MANAGE_GUILD_COMMANDS = {"djrole", "djmode", "vote_mode"}


def _has_manage_guild(member) -> bool:
permissions = getattr(member, "guild_permissions", None)
return bool(
permissions
and (permissions.administrator or permissions.manage_guild)
)


def install_settings_permission_guard(bot) -> None:
"""Protect both prefix and application-command entry points."""

async def prefix_settings_check(ctx):
command = getattr(ctx, "command", None)
if not command or command.qualified_name not in RESTRICTED_MANAGE_GUILD_COMMANDS:
return True
if ctx.guild and _has_manage_guild(ctx.author):
return True
try:
await ctx.send(
"You need Manage Server permission to change this setting.",
delete_after=10,
)
except Exception:
pass
return False

bot.add_check(prefix_settings_check)

previous_interaction_check = bot.tree.interaction_check

async def secured_interaction_check(interaction: discord.Interaction) -> bool:
if previous_interaction_check is not None:
allowed = await previous_interaction_check(interaction)
if not allowed:
return False

command = getattr(interaction, "command", None)
if not command or command.qualified_name not in RESTRICTED_MANAGE_GUILD_COMMANDS:
return True

member = interaction.user
if interaction.guild and _has_manage_guild(member):
return True

try:
if not interaction.response.is_done():
await interaction.response.send_message(
"You need Manage Server permission to change this setting.",
ephemeral=True,
)
else:
await interaction.followup.send(
"You need Manage Server permission to change this setting.",
ephemeral=True,
)
except Exception:
pass
return False

bot.tree.interaction_check = secured_interaction_check
Loading
Loading