Skip to content

Commit 282b0ab

Browse files
committed
Sign callback data to prevent tampering with it
1 parent f953bd8 commit 282b0ab

File tree

8 files changed

+220
-34
lines changed

8 files changed

+220
-34
lines changed

botogram/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from .runner import run
3535
from .objects import *
3636
from .utils import usernames_in
37-
from .callbacks import buttons
3837

3938

4039
# This code will simulate the Windows' multiprocessing behavior if the

botogram/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,7 @@ def file_content(self, path):
182182
response = requests.get(url)
183183

184184
return response.content
185+
186+
@property
187+
def token(self):
188+
return self._api_key

botogram/callbacks.py

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,34 @@
1717
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
1818
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
1919

20+
import base64
21+
import binascii
22+
import hashlib
23+
24+
from . import crypto
25+
26+
27+
DIGEST = hashlib.md5
28+
DIGEST_LEN = 16
29+
2030

2131
class ButtonsRow:
2232
"""A row of an inline keyboard"""
2333

24-
def __init__(self):
34+
def __init__(self, bot):
2535
self._content = []
36+
self._bot = bot
2637

2738
def url(self, label, url):
2839
"""Open an URL when the button is pressed"""
2940
self._content.append({"text": label, "url": url})
3041

3142
def callback(self, label, callback, data=None):
3243
"""Trigger a callback when the button is pressed"""
33-
if data is not None:
34-
msg = "%s\0%s" % (callback, data)
35-
else:
36-
msg = callback
37-
38-
self._content.append({"text": label, "callback_data": msg})
44+
self._content.append({
45+
"text": label,
46+
"callback_data": get_callback_data(self._bot, callback, data),
47+
})
3948

4049
def switch_inline_query(self, label, query="", current_chat=False):
4150
"""Switch the user to this bot's inline query"""
@@ -54,12 +63,13 @@ def switch_inline_query(self, label, query="", current_chat=False):
5463
class Buttons:
5564
"""Factory for inline keyboards"""
5665

57-
def __init__(self):
66+
def __init__(self, bot):
5867
self._rows = {}
68+
self._bot = bot
5969

6070
def __getitem__(self, index):
6171
if index not in self._rows:
62-
self._rows[index] = ButtonsRow()
72+
self._rows[index] = ButtonsRow(self._bot)
6373
return self._rows[index]
6474

6575
def _serialize_attachment(self):
@@ -72,27 +82,71 @@ def _serialize_attachment(self):
7282
return {"inline_keyboard": rows}
7383

7484

75-
def buttons():
76-
"""Create a new inline keyboard"""
77-
return Buttons()
85+
def parse_callback_data(bot, raw):
86+
"""Parse the callback data generated by botogram and return it"""
87+
raw = raw.encode("utf-8")
7888

89+
if len(raw) < 32:
90+
raise crypto.TamperedMessageError
7991

80-
def parse_callback_data(data):
81-
"""Parse the callback data generated by botogram and return it"""
82-
if "\0" in data:
83-
name, custom = data.split("\0", 1)
84-
return name, custom
85-
else:
86-
return data, None
92+
try:
93+
prelude = base64.b64decode(raw[:32])
94+
except binascii.Error:
95+
raise crypto.TamperedMessageError
96+
97+
signature = prelude[:16]
98+
name = prelude[16:]
99+
data = raw[32:]
100+
101+
if not crypto.compare(crypto.get_hmac(bot, name + data), signature):
102+
raise crypto.TamperedMessageError
103+
104+
return name, data.decode("utf-8")
105+
106+
107+
def get_callback_data(bot, name, data=None):
108+
"""Get the callback data for the provided name and data"""
109+
name = hashed_callback_name(name)
110+
111+
if data is None:
112+
data = ""
113+
data = data.encode("utf-8")
114+
115+
if len(data) > 32:
116+
raise ValueError(
117+
"The provided data is too big (%s bytes), try to reduce it to "
118+
"32 bytes" % len(data)
119+
)
120+
121+
# Get the signature of the hook name and data
122+
signature = crypto.get_hmac(bot, name + data)
123+
124+
# Base64 the signature and the hook name together to save space
125+
return (base64.b64encode(signature + name) + data).decode("utf-8")
126+
127+
128+
def hashed_callback_name(name):
129+
"""Get the hashed name of a callback"""
130+
# Get only the first 8 bytes of the hash to fit it into the payload
131+
return DIGEST(name.encode("utf-8")).digest()[:8]
87132

88133

89134
def process(bot, chains, update):
90135
"""Process a callback sent to the bot"""
136+
try:
137+
name, data = parse_callback_data(bot, update.callback_query._data)
138+
except crypto.TamperedMessageError:
139+
bot.logger.warn(
140+
"The user tampered with the #%s update's data. Skipped it."
141+
% update.update_id
142+
)
143+
return
144+
91145
for hook in chains["callbacks"]:
92146
bot.logger.debug("Processing update #%s with the hook %s" %
93147
(update.update_id, hook.name))
94148

95-
result = hook.call(bot, update)
149+
result = hook.call(bot, update, name, data)
96150
if result is True:
97151
bot.logger.debug("Update #%s was just processed by the %s hook" %
98152
(update.update_id, hook.name))

botogram/crypto.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright (c) 2015-2017 The Botogram Authors (see AUTHORS)
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19+
# DEALINGS IN THE SOFTWARE.
20+
21+
import hmac
22+
23+
24+
DIGEST = "md5"
25+
DIGEST_LEN = 16
26+
27+
28+
class TamperedMessageError(Exception):
29+
pass
30+
31+
32+
def generate_secret_key(bot):
33+
"""Generate the secret key of the bot"""
34+
mac = hmac.new(bot.api.token.encode("utf-8"), digestmod=DIGEST)
35+
mac.update(b"botogram" + bot.itself.username.encode("utf-8"))
36+
return mac.digest()
37+
38+
39+
def get_hmac(bot, data):
40+
"""Get the HMAC of a piece of data"""
41+
mac = hmac.new(generate_secret_key(bot), digestmod=DIGEST)
42+
mac.update(data)
43+
return mac.digest()
44+
45+
46+
def sign_data(bot, data):
47+
"""Return a signed version of the data, to prevent tampering with it"""
48+
return get_hmac(bot, data) + data
49+
50+
51+
def verify_signature(bot, untrusted):
52+
"""Check if the untrusted data is correctly signed, and return it"""
53+
if len(untrusted) < DIGEST_LEN:
54+
raise TamperedMessageError
55+
56+
signature = untrusted[:DIGEST_LEN]
57+
data = untrusted[DIGEST_LEN:]
58+
59+
if not hmac.compare_digest(get_hmac(bot, data), signature):
60+
raise TamperedMessageError
61+
62+
return data
63+
64+
65+
def compare(a, b):
66+
"""Safely compare two values"""
67+
return hmac.compare_digest(a, b)

botogram/frozenbot.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from . import utils
2424
from . import objects
2525
from . import api as api_module
26+
from .callbacks import Buttons
2627

2728

2829
class FrozenBotError(Exception):
@@ -234,6 +235,10 @@ def register_update_processor(self, kind, processor):
234235
"""Register a new update processor"""
235236
raise FrozenBotError("Can't register new update processors at runtime")
236237

238+
def buttons(self):
239+
"""Create a new inline keyboard"""
240+
return Buttons(self)
241+
237242
# This helper manages the translation
238243

239244
def _(self, message, **args):

botogram/hooks.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import re
2121

22-
from .callbacks import parse_callback_data
22+
from .callbacks import hashed_callback_name
2323

2424

2525
class Hook:
@@ -212,14 +212,15 @@ class CallbackHook(Hook):
212212
"""Underlying hook for @bot.callback"""
213213

214214
def _after_init(self, args):
215-
self._name = "%s:%s" % (self.component.component_name, args["name"])
215+
self._name = hashed_callback_name(
216+
"%s:%s" % (self.component.component_name, args["name"])
217+
)
216218

217-
def _call(self, bot, update):
219+
def call(self, bot, update, name, data):
218220
if not update.callback_query:
219221
return
220222
q = update.callback_query
221223

222-
name, data = parse_callback_data(q._data)
223224
if name != self._name:
224225
return
225226

tests/test_callbacks.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020

2121
import json
2222

23-
from botogram.callbacks import Buttons, parse_callback_data
23+
from botogram.callbacks import Buttons, parse_callback_data, get_callback_data
24+
from botogram.callbacks import hashed_callback_name
2425

2526

26-
def test_buttons():
27-
buttons = Buttons()
27+
def test_buttons(bot):
28+
buttons = Buttons(bot)
2829
buttons[0].url("test 1", "http://example.com")
2930
buttons[0].callback("test 2", "test_callback")
3031
buttons[3].callback("test 3", "another_callback", "data")
@@ -35,20 +36,30 @@ def test_buttons():
3536
"inline_keyboard": [
3637
[
3738
{"text": "test 1", "url": "http://example.com"},
38-
{"text": "test 2", "callback_data": "test_callback"},
39+
{
40+
"text": "test 2",
41+
"callback_data": get_callback_data(bot, "test_callback"),
42+
},
3943
],
4044
[
4145
{"text": "test 4", "switch_inline_query": ""},
4246
{"text": "test 5", "switch_inline_query_current_chat": "wow"},
4347
],
4448
[
45-
{"text": "test 3", "callback_data": "another_callback\0data"},
49+
{
50+
"text": "test 3",
51+
"callback_data": get_callback_data(
52+
bot, "another_callback", "data"
53+
),
54+
},
4655
],
4756
],
4857
}
4958

5059

51-
def test_parse_callback_data():
52-
assert parse_callback_data("test") == ("test", None)
53-
assert parse_callback_data("test:something") == ("test:something", None)
54-
assert parse_callback_data("test\0wow") == ("test", "wow")
60+
def test_parse_callback_data(bot):
61+
raw = get_callback_data(bot, "test_callback", "this is some data!")
62+
assert parse_callback_data(bot, raw) == (
63+
hashed_callback_name("test_callback"),
64+
"this is some data!",
65+
)

tests/test_crypto.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) 2015-2017 The Botogram Authors (see AUTHORS)
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19+
# DEALINGS IN THE SOFTWARE.
20+
21+
import pytest
22+
23+
from botogram.crypto import generate_secret_key, get_hmac, sign_data
24+
from botogram.crypto import TamperedMessageError, verify_signature
25+
26+
27+
def test_generate_secret_key(bot):
28+
# Check if the generated key for the test bot is correct
29+
key = generate_secret_key(bot)
30+
assert key == b'\\\x93aA\xe8\x8d\x9aL\x8c\xfd\x81,D\xeaj\xd0'
31+
32+
33+
def test_hmac(bot):
34+
expect = b'q\x06\x9c\xc1\xfa\xd1n\xe8\xef\x17\xf6\xd7Z\xb0G\x7f'
35+
assert get_hmac(bot, b'test data') == expect
36+
37+
signed = sign_data(bot, b'test string')
38+
assert verify_signature(bot, signed) == b'test string'
39+
40+
signed += b'a'
41+
with pytest.raises(TamperedMessageError):
42+
verify_signature(bot, signed)
43+
44+
with pytest.raises(TamperedMessageError):
45+
verify_signature(bot, b'a')

0 commit comments

Comments
 (0)