Skip to content

Commit 59c84dc

Browse files
Invalidate Discord authorization tokens on public pastes (#36)
* first run at handling discord tokens * only invlidate on public pastes * add note in docs * now handle locking the resource and consuming from a bucket/cache instead of on-demand * compartmentalize correctly and allow configurable sleep between retries for token bucket timing * needless coroutine * update example config * Update views/api.py Co-authored-by: Lilly Rose Berner <[email protected]> * log gist creation/error and handle appropriately * Handle network connection issues as best we can * Update views/api.py Co-authored-by: Lilly Rose Berner <[email protected]> * fix logging message * migrate handling over to database code * remove previous needless assignment * add annotation amendment --------- Co-authored-by: Lilly Rose Berner <[email protected]>
1 parent d89fe87 commit 59c84dc

File tree

8 files changed

+167
-14
lines changed

8 files changed

+167
-14
lines changed

config.template.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ global_limit = { rate = 21600, per = 86400, priority = 1, bucket = "ip" }
2323
char_limit = 300_000
2424
file_limit = 5
2525
name_limit = 25
26+
27+
[GITHUB] # optional key
28+
token = "..." # a github token capable of creating gists, non-optional if the above key is provided
29+
timeout = 10 # how long to wait between posting gists if there's an influx of tokens posted. Non-optional

core/database.py

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616
along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
"""
1818

19+
from __future__ import annotations
20+
1921
import asyncio
2022
import datetime
2123
import logging
24+
import re
2225
from typing import TYPE_CHECKING, Any, Self
2326

27+
import aiohttp
2428
import asyncpg
2529

2630
from core import CONFIG
@@ -31,26 +35,114 @@
3135

3236
if TYPE_CHECKING:
3337
_Pool = asyncpg.Pool[asyncpg.Record]
38+
from types_.config import Github
39+
from types_.github import PostGist
3440
else:
3541
_Pool = asyncpg.Pool
3642

37-
38-
logger: logging.Logger = logging.getLogger(__name__)
43+
DISCORD_TOKEN_REGEX: re.Pattern[str] = re.compile(r"[a-zA-Z0-9_-]{23,28}\.[a-zA-Z0-9_-]{6,7}\.[a-zA-Z0-9_-]{27,}")
44+
LOGGER: logging.Logger = logging.getLogger(__name__)
3945

4046

4147
class Database:
4248
pool: _Pool
4349

44-
def __init__(self, *, dsn: str) -> None:
50+
def __init__(self, *, dsn: str, session: aiohttp.ClientSession | None = None, github_config: Github | None) -> None:
4551
self._dsn: str = dsn
52+
self.session: aiohttp.ClientSession | None = session
53+
self._handling_tokens = bool(self.session and github_config)
54+
55+
if self._handling_tokens:
56+
LOGGER.info("Will handle compromised discord info.")
57+
assert github_config # guarded by if here
58+
59+
self._gist_token = github_config["token"]
60+
self._gist_timeout = github_config["timeout"]
61+
# tokens bucket for gist posting: {paste_id: token\ntoken}
62+
self.__tokens_bucket: dict[str, str] = {}
63+
self.__token_lock = asyncio.Lock()
64+
self.__token_task = asyncio.create_task(self._token_task())
4665

4766
async def __aenter__(self) -> Self:
4867
await self.connect()
4968
return self
5069

5170
async def __aexit__(self, *_: Any) -> None:
71+
task: asyncio.Task[None] | None = getattr(self, "__token_task", None)
72+
if task:
73+
task.cancel()
74+
5275
await self.close()
5376

77+
async def _token_task(self) -> None:
78+
# won't run unless pre-reqs are met in __init__
79+
while True:
80+
if self.__tokens_bucket:
81+
async with self.__token_lock:
82+
await self._post_gist_of_tokens()
83+
84+
await asyncio.sleep(self._gist_timeout)
85+
86+
def _handle_discord_tokens(self, *bodies: dict[str, str], paste_id: str) -> None:
87+
formatted_bodies = "\n".join(b["content"] for b in bodies)
88+
89+
tokens = list(DISCORD_TOKEN_REGEX.finditer(formatted_bodies))
90+
91+
if not tokens:
92+
return
93+
94+
LOGGER.info(
95+
"Discord bot token located and added to token bucket. Current bucket size is: %s", len(self.__tokens_bucket)
96+
)
97+
98+
tokens = "\n".join([m[0] for m in tokens])
99+
self.__tokens_bucket[paste_id] = tokens
100+
101+
async def _post_gist_of_tokens(self) -> None:
102+
assert self.session # guarded in caller
103+
json_payload: PostGist = {
104+
"description": "MystBin found these Discord tokens in a public paste, and posted them here to invalidate them. If you intended to share these, please apply a password to the paste.",
105+
"files": {},
106+
"public": True,
107+
}
108+
109+
github_headers = {
110+
"Accept": "application/vnd.github+json",
111+
"Authorization": f"Bearer {self._gist_token}",
112+
"X-GitHub-Api-Version": "2022-11-28",
113+
}
114+
115+
current_tokens = self.__tokens_bucket
116+
self.__tokens_bucket = {}
117+
118+
for paste_id, tokens in current_tokens.items():
119+
filename = str(datetime.datetime.now(datetime.UTC)) + "-tokens.txt"
120+
json_payload["files"][filename] = {"content": f"https://mystb.in/{paste_id}:\n{tokens}"}
121+
122+
success = False
123+
124+
try:
125+
async with self.session.post(
126+
"https://api.github.com/gists", headers=github_headers, json=json_payload
127+
) as resp:
128+
success = resp.ok
129+
130+
if not success:
131+
response_body = await resp.text()
132+
LOGGER.error(
133+
"Failed to create gist with token bucket with response status code %s and response body:\n\n%s",
134+
resp.status,
135+
response_body,
136+
)
137+
except (aiohttp.ClientError, aiohttp.ClientOSError) as error:
138+
success = False
139+
LOGGER.error("Failed to handle gist creation due to a client or operating system error", exc_info=error)
140+
141+
if success:
142+
LOGGER.info("Gist created and invalidated tokens from %s pastes.", len(current_tokens))
143+
else:
144+
self.__tokens_bucket.update(current_tokens)
145+
54146
async def connect(self) -> None:
55147
try:
56148
pool: asyncpg.Pool[asyncpg.Record] | None = await asyncpg.create_pool(dsn=self._dsn)
@@ -64,15 +156,15 @@ async def connect(self) -> None:
64156
await pool.execute(fp.read())
65157

66158
self.pool = pool
67-
logger.info("Successfully connected to the database.")
159+
LOGGER.info("Successfully connected to the database.")
68160

69161
async def close(self) -> None:
70162
try:
71163
await asyncio.wait_for(self.pool.close(), timeout=10)
72164
except TimeoutError:
73-
logger.warning("Failed to greacefully close the database connection...")
165+
LOGGER.warning("Failed to greacefully close the database connection...")
74166
else:
75-
logger.info("Successfully closed the database connection.")
167+
LOGGER.info("Successfully closed the database connection.")
76168

77169
async def fetch_paste(self, identifier: str, *, password: str | None) -> PasteModel | None:
78170
assert self.pool
@@ -159,6 +251,8 @@ async def create_paste(self, *, data: dict[str, Any]) -> PasteModel:
159251
tokens = [t for t in utils.TOKEN_REGEX.findall(content) if utils.validate_discord_token(t)]
160252
if tokens:
161253
annotation = "Contains possibly sensitive information: Discord Token(s)"
254+
if not password:
255+
annotation += ", which have now been invalidated."
162256

163257
row: asyncpg.Record | None = await connection.fetchrow(
164258
file_query, paste.id, content, name, loc, annotation
@@ -167,7 +261,13 @@ async def create_paste(self, *, data: dict[str, Any]) -> PasteModel:
167261
if row:
168262
paste.files.append(FileModel(row))
169263

170-
return paste
264+
if not password:
265+
# if the user didn't provide a password (a public paste)
266+
# we check for discord tokens
267+
LOGGER.info("Located tokens")
268+
self._handle_discord_tokens(*data["files"], paste_id=paste.id)
269+
270+
return paste
171271

172272
async def fetch_paste_security(self, *, token: str) -> PasteModel | None:
173273
query: str = """SELECT * FROM pastes WHERE safety = $1"""

core/server.py

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

1919
import logging
2020

21+
import aiohttp
2122
import starlette_plus
2223
from starlette.middleware import Middleware
2324
from starlette.routing import Mount, Route
@@ -34,11 +35,16 @@
3435

3536

3637
class Application(starlette_plus.Application):
37-
def __init__(self, *, database: Database) -> None:
38+
def __init__(self, *, database: Database, session: aiohttp.ClientSession | None = None) -> None:
3839
self.database: Database = database
40+
self.session: aiohttp.ClientSession | None = session
3941
self.schemas: SchemaGenerator | None = None
4042

41-
views: list[starlette_plus.View] = [HTMXView(self), APIView(self), DocsView(self)]
43+
views: list[starlette_plus.View] = [
44+
HTMXView(self),
45+
APIView(self),
46+
DocsView(self),
47+
]
4248
routes: list[Mount | Route] = [Mount("/static", app=StaticFiles(directory="web/static"), name="static")]
4349

4450
limit_redis = starlette_plus.Redis(url=CONFIG["REDIS"]["limiter"]) if CONFIG["REDIS"]["limiter"] else None

launcher.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import asyncio
2020
import logging
2121

22+
import aiohttp
2223
import starlette_plus
2324
import uvicorn
2425

@@ -31,7 +32,9 @@
3132

3233

3334
async def main() -> None:
34-
async with core.Database(dsn=core.CONFIG["DATABASE"]["dsn"]) as database:
35+
async with aiohttp.ClientSession() as session, core.Database(
36+
dsn=core.CONFIG["DATABASE"]["dsn"], session=session, github_config=core.CONFIG.get("GITHUB")
37+
) as database:
3538
app: core.Application = core.Application(database=database)
3639

3740
host: str = core.CONFIG["SERVER"]["host"]

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ asyncpg-stubs
55
bleach
66
humanize
77
python-multipart
8-
pyyaml
8+
pyyaml
9+
aiohttp

types_/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
"""
1818

19-
from typing import TypedDict
19+
from typing import NotRequired, TypedDict
2020

2121
import starlette_plus
2222

@@ -52,9 +52,15 @@ class Pastes(TypedDict):
5252
name_limit: int
5353

5454

55+
class Github(TypedDict):
56+
token: str
57+
timeout: float
58+
59+
5560
class Config(TypedDict):
5661
SERVER: Server
5762
DATABASE: Database
5863
REDIS: Redis
5964
LIMITS: Limits
6065
PASTES: Pastes
66+
GITHUB: NotRequired[Github]

types_/github.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""MystBin. Share code easily.
2+
3+
Copyright (C) 2020-Current PythonistaGuild
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
"""
18+
19+
from typing import TypedDict
20+
21+
22+
class GistContent(TypedDict):
23+
content: str
24+
25+
26+
class PostGist(TypedDict):
27+
description: str
28+
files: dict[str, GistContent]
29+
public: bool

views/api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ async def paste_post(self, request: starlette_plus.Request) -> starlette_plus.Re
171171
172172
Max file limit is `5`.\n\n
173173
174+
If the paste is regarded as public, and contains Discord authorization tokens,
175+
then these will be invalidated upon paste creation.\n\n
176+
174177
requestBody:
175178
description: The paste data. `password` and `expires` are optional.
176179
content:
@@ -245,7 +248,6 @@ async def paste_post(self, request: starlette_plus.Request) -> starlette_plus.Re
245248
type: string
246249
example: You are requesting too fast.
247250
"""
248-
249251
content_type: str | None = request.headers.get("content-type", None)
250252
body: dict[str, Any] | str
251253
data: dict[str, Any]
@@ -259,6 +261,7 @@ async def paste_post(self, request: starlette_plus.Request) -> starlette_plus.Re
259261
body = (await request.body()).decode(encoding="UTF-8")
260262

261263
data = {"files": [{"content": body, "filename": None}]} if isinstance(body, str) else body
264+
262265
if resp := validate_paste(data):
263266
return resp
264267

@@ -270,9 +273,10 @@ async def paste_post(self, request: starlette_plus.Request) -> starlette_plus.Re
270273
return starlette_plus.JSONResponse({"error": f'Unable to parse "expiry" parameter: {e}'}, status_code=400)
271274

272275
data["expires"] = expiry
273-
data["password"] = data.get("password", None)
276+
data["password"] = data.get("password")
274277

275278
paste = await self.app.database.create_paste(data=data)
279+
276280
to_return: dict[str, Any] = paste.serialize(exclude=["password", "password_ok"])
277281
to_return.pop("files", None)
278282

0 commit comments

Comments
 (0)