Skip to content
Commits on Source (3)
# -*- coding: utf-8 -*-
# Copyright(C) 2021 Vincent A
#
# This file is part of a woob module.
#
# This woob module is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This woob module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this woob module. If not, see <http://www.gnu.org/licenses/>.
import string
alphabet = f"{string.digits}{string.ascii_uppercase}{string.ascii_lowercase}"
for remove in "O0Il":
alphabet = alphabet.replace(remove, "")
alphabet = alphabet.encode("ascii")
ralphabet = {b: n for n, b in enumerate(alphabet)}
def encode(input):
input = int.from_bytes(input, "big")
output = bytearray()
while input:
input, index = divmod(input, 58)
output.append(alphabet[index])
return bytes(reversed(output))
def decode(input):
nb = sum(ralphabet[val] * 58 ** pos for pos, val in enumerate(reversed(input)))
output = bytearray()
# warning: might ignore trailing nulls
while nb:
nb, b = divmod(nb, 256)
output.append(b)
return bytes(reversed(output))
......@@ -24,7 +24,7 @@
from woob.capabilities.date import DateField
from woob.capabilities.paste import BasePaste
from .pages import ReadPage, WritePage, encrypt
from .pages import ReadPage, WritePage, encrypt, IndexPage
class PrivatePaste(BasePaste):
......@@ -38,7 +38,7 @@ def page_url(self):
class JsonURL(URL):
def handle(self, response):
if response.headers.get('content-type') != 'application/json':
if not response.headers.get('content-type').startswith('application/json'):
return
return super(JsonURL, self).handle(response)
......@@ -48,6 +48,7 @@ class PrivatebinBrowser(PagesBrowser):
read_page = JsonURL(r'/\?(?P<id>[\w+-]+)$', ReadPage)
write_page = JsonURL('/', WritePage)
index_page = URL('/$', IndexPage)
def __init__(self, baseurl, opendiscussion, *args, **kwargs):
super(PrivatebinBrowser, self).__init__(*args, **kwargs)
......@@ -91,14 +92,20 @@ def get_paste(self, id):
return ret
def can_post(self, contents, max_age):
if max_age not in WritePage.AGES:
self.index_page.go()
duration_s = self.page.duration_to_str(max_age)
if duration_s is None:
return 0
# TODO reject binary files on zerobin?
return 1
def post_paste(self, p, max_age):
to_post, url_key = encrypt(p.contents)
self.index_page.go()
duration_s = self.page.duration_to_str(max_age)
assert duration_s
to_post, url_key = encrypt(p.contents, expire_string=duration_s)
self.location(self.BASEURL, json=to_post, headers={'Accept': 'application/json'})
self.page.fill_paste(p)
......
......@@ -32,12 +32,13 @@
from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA256
from Crypto.Protocol.KDF import PBKDF2
# privatebin uses base64 AND base58... why on earth are they so inconsistent?
from base58 import b58decode, b58encode
from woob.browser.pages import JsonPage
from woob.browser.pages import JsonPage, HTMLPage
from woob.tools.json import json
# privatebin uses base64 AND base58... why on earth are they so inconsistent?
from . import base58
class ReadPage(JsonPage):
def decode_paste(self, textkey):
......@@ -59,20 +60,20 @@ def fix_base64(s):
return s + pad.get(len(s) % 4, '')
class WritePage(JsonPage):
AGES = {
300: '5min',
600: '10min',
3600: '1hour',
86400: '1day',
7 * 86400: '1week',
30 * 86400: '1month',
30.5 * 86400: '1month', # pastoob's definition of "1 month" is approximately okay
365 * 86400: '1year',
None: 'never',
0: 0,
}
ALL_AGES = [
(365 * 86400, '1year'),
(30.5 * 86400, '1month'), # pastoob's definition of "1 month" is approximately okay
(30 * 86400, '1month'),
(7 * 86400, '1week'),
(86400, '1day'),
(3600, '1hour'),
(600, '10min'),
(300, '5min'),
(None, 'never'),
]
class WritePage(JsonPage):
def fill_paste(self, obj):
obj._serverid = self.doc["id"]
obj._deletetoken = self.doc["deletetoken"]
......@@ -93,7 +94,7 @@ def decrypt(textkey, params):
keylen //= 8
# not base64, but base58, just because.
key = derive_key(b58decode(textkey), salt, keylen, iterations)
key = derive_key(base58.decode(textkey.encode("ascii")), salt, keylen, iterations)
data = b64decode(params['ct'])
ciphertext = data[:-taglen]
......@@ -173,5 +174,39 @@ def encrypt(plaintext, expire_string="1week", burn_after_reading=False, discussi
"meta": {
"expire": expire_string,
},
}, b58encode(url_bin_key).decode("ascii"),
}, base58.encode(url_bin_key).decode("ascii"),
)
class IndexPage(HTMLPage):
def get_supported_duration_strings(self):
return set(self.doc.xpath("//select[@id='pasteExpiration']/option/@value"))
def get_supported_durations(self):
supported = self.get_supported_duration_strings()
for secs, name in ALL_AGES:
if name in supported:
self.logger.debug("duration %r (%r) is supported", name, secs)
yield (secs, name)
def duration_to_str(self, max_secs):
supported = list(self.get_supported_durations())
self.logger.debug("trying duration %r", max_secs)
if max_secs is None or max_secs is False:
if supported[-1][1] == "never":
# too lazy to search the whole list
return "never"
return None
for secs, name in supported:
if secs is not None and max_secs >= secs:
if (max_secs - secs) > (max_secs / 10):
# example: the poster desires the paste to expire in maximum 1year
# but the pastebin supports only 5minutes expiration
# it's technically correct to set 5 minutes
# but probably not what the poster wants...
# reject durations which are more than 10% shorter than the desired expiration
self.logger.debug("rejecting matching %r (%r) because it's >10%% shorter", name, secs)
else:
return name
......@@ -27,7 +27,9 @@ class PrivatebinTest(BackendTest):
def test_writeread(self):
p = self.backend.new_paste(_id=None, contents='woob test')
self.backend.browser.post_paste(p, 86400)
# 1day should exist on most instances
assert self.backend.browser.can_post(p, max_age=86400)
self.backend.browser.post_paste(p, max_age=86400)
assert p.url
assert p.id
......@@ -41,3 +43,14 @@ def test_writeread(self):
p3 = self.backend.get_paste(p.url)
self.assertEqual(p.id, p3.id)
def test_too_far_expiry(self):
p = self.backend.new_paste(_id=None, contents='woob test')
# 10 years should not be supported
assert not self.backend.browser.can_post(p, max_age=86400 * 365 * 10)
try:
assert not self.backend.browser.post_paste(p, max_age=86400 * 365 * 10)
except Exception:
pass
else:
raise AssertionError("should have failed posting")