+167
-30
fhost.py
+167
-30
fhost.py
···
19
19
and limitations under the License.
20
20
"""
21
21
22
-
from flask import Flask, abort, make_response, redirect, request, send_from_directory, url_for, Response, render_template
22
+
from flask import Flask, abort, make_response, redirect, request, send_from_directory, url_for, Response, render_template, Request
23
23
from flask_sqlalchemy import SQLAlchemy
24
24
from flask_migrate import Migrate
25
25
from sqlalchemy import and_, or_
26
+
from sqlalchemy.orm import declared_attr
27
+
import sqlalchemy.types as types
26
28
from jinja2.exceptions import *
27
29
from jinja2 import ChoiceLoader, FileSystemLoader
28
30
from hashlib import sha256
29
31
from magic import Magic
30
32
from mimetypes import guess_extension
31
33
import click
34
+
import enum
32
35
import os
33
36
import sys
34
37
import time
35
38
import datetime
39
+
import ipaddress
36
40
import typing
37
41
import requests
38
42
import secrets
43
+
import re
39
44
from validators import url as url_valid
40
45
from pathlib import Path
41
46
···
63
68
"text/plain" : ".txt",
64
69
"text/x-diff" : ".diff",
65
70
},
66
-
FHOST_MIME_BLACKLIST = [
67
-
"application/x-dosexec",
68
-
"application/java-archive",
69
-
"application/java-vm"
70
-
],
71
-
FHOST_UPLOAD_BLACKLIST = None,
72
71
NSFW_DETECT = False,
73
72
NSFW_THRESHOLD = 0.92,
74
73
VSCAN_SOCKET = None,
···
129
128
130
129
return u
131
130
131
+
class IPAddress(types.TypeDecorator):
132
+
impl = types.LargeBinary
133
+
cache_ok = True
134
+
135
+
def process_bind_param(self, value, dialect):
136
+
match value:
137
+
case ipaddress.IPv6Address():
138
+
value = (value.ipv4_mapped or value).packed
139
+
case ipaddress.IPv4Address():
140
+
value = value.packed
141
+
142
+
return value
143
+
144
+
def process_result_value(self, value, dialect):
145
+
if value is not None:
146
+
value = ipaddress.ip_address(value)
147
+
if type(value) is ipaddress.IPv6Address:
148
+
value = value.ipv4_mapped or value
149
+
150
+
return value
151
+
152
+
132
153
class File(db.Model):
133
154
id = db.Column(db.Integer, primary_key = True)
134
155
sha256 = db.Column(db.String, unique = True)
135
156
ext = db.Column(db.UnicodeText)
136
157
mime = db.Column(db.UnicodeText)
137
-
addr = db.Column(db.UnicodeText)
158
+
addr = db.Column(IPAddress(16))
138
159
ua = db.Column(db.UnicodeText)
139
160
removed = db.Column(db.Boolean, default=False)
140
161
nsfw_score = db.Column(db.Float)
···
227
248
else:
228
249
mime = file_.content_type
229
250
230
-
if mime in app.config["FHOST_MIME_BLACKLIST"] or guess in app.config["FHOST_MIME_BLACKLIST"]:
231
-
abort(415)
232
-
233
251
if len(mime) > 128:
234
252
abort(400)
253
+
254
+
for flt in MIMEFilter.query.all():
255
+
if flt.check(guess):
256
+
abort(403, flt.reason)
235
257
236
258
if mime.startswith("text/") and not "charset" in mime:
237
259
mime += "; charset=utf-8"
···
308
330
return f, isnew
309
331
310
332
333
+
class RequestFilter(db.Model):
334
+
__tablename__ = "request_filter"
335
+
id = db.Column(db.Integer, primary_key=True)
336
+
type = db.Column(db.String(20), index=True, nullable=False)
337
+
comment = db.Column(db.UnicodeText)
338
+
339
+
__mapper_args__ = {
340
+
"polymorphic_on": type,
341
+
"with_polymorphic": "*",
342
+
"polymorphic_identity": "empty"
343
+
}
344
+
345
+
def __init__(self, comment: str = None):
346
+
self.comment = comment
347
+
348
+
349
+
class AddrFilter(RequestFilter):
350
+
addr = db.Column(IPAddress(16), unique=True)
351
+
352
+
__mapper_args__ = {"polymorphic_identity": "addr"}
353
+
354
+
def __init__(self, addr: ipaddress._BaseAddress, comment: str = None):
355
+
self.addr = addr
356
+
super().__init__(comment=comment)
357
+
358
+
def check(self, addr: ipaddress._BaseAddress) -> bool:
359
+
if type(addr) is ipaddress.IPv6Address:
360
+
addr = addr.ipv4_mapped or addr
361
+
return addr == self.addr
362
+
363
+
def check_request(self, r: Request) -> bool:
364
+
return self.check(ipaddress.ip_address(r.remote_addr))
365
+
366
+
@property
367
+
def reason(self) -> str:
368
+
return f"Your IP Address ({self.addr.compressed}) is blocked from " \
369
+
"uploading files."
370
+
371
+
372
+
class IPNetwork(types.TypeDecorator):
373
+
impl = types.Text
374
+
cache_ok = True
375
+
376
+
def process_bind_param(self, value, dialect):
377
+
if value is not None:
378
+
value = value.compressed
379
+
380
+
return value
381
+
382
+
def process_result_value(self, value, dialect):
383
+
if value is not None:
384
+
value = ipaddress.ip_network(value)
385
+
386
+
return value
387
+
388
+
389
+
class NetFilter(RequestFilter):
390
+
net = db.Column(IPNetwork)
391
+
392
+
__mapper_args__ = {"polymorphic_identity": "net"}
393
+
394
+
def __init__(self, net: ipaddress._BaseNetwork, comment: str = None):
395
+
self.net = net
396
+
super().__init__(comment=comment)
397
+
398
+
def check(self, addr: ipaddress._BaseAddress) -> bool:
399
+
if type(addr) is ipaddress.IPv6Address:
400
+
addr = addr.ipv4_mapped or addr
401
+
return addr in self.net
402
+
403
+
def check_request(self, r: Request) -> bool:
404
+
return self.check(ipaddress.ip_address(r.remote_addr))
405
+
406
+
@property
407
+
def reason(self) -> str:
408
+
return f"Your network ({self.net.compressed}) is blocked from " \
409
+
"uploading files."
410
+
411
+
412
+
class HasRegex:
413
+
@declared_attr
414
+
def regex(cls):
415
+
return cls.__table__.c.get("regex", db.Column(db.UnicodeText))
416
+
417
+
def check(self, s: str) -> bool:
418
+
return re.match(self.regex, s) is not None
419
+
420
+
421
+
class MIMEFilter(HasRegex, RequestFilter):
422
+
__mapper_args__ = {"polymorphic_identity": "mime"}
423
+
424
+
def __init__(self, mime_regex: str, comment: str = None):
425
+
self.regex = mime_regex
426
+
super().__init__(comment=comment)
427
+
428
+
def check_request(self, r: Request) -> bool:
429
+
if "file" in r.files:
430
+
return self.check(r.files["file"].mimetype)
431
+
432
+
return False
433
+
434
+
@property
435
+
def reason(self) -> str:
436
+
return "File MIME type not allowed."
437
+
438
+
439
+
class UAFilter(HasRegex, RequestFilter):
440
+
__mapper_args__ = {"polymorphic_identity": "ua"}
441
+
442
+
def __init__(self, ua_regex: str, comment: str = None):
443
+
self.regex = ua_regex
444
+
super().__init__(comment=comment)
445
+
446
+
def check_request(self, r: Request) -> bool:
447
+
return self.check(r.user_agent.string)
448
+
449
+
@property
450
+
def reason(self) -> str:
451
+
return "User agent not allowed."
452
+
453
+
311
454
class UrlEncoder(object):
312
455
def __init__(self,alphabet, min_length):
313
456
self.alphabet = alphabet
···
351
494
352
495
return u.geturl()
353
496
354
-
def in_upload_bl(addr):
355
-
if app.config["FHOST_UPLOAD_BLACKLIST"]:
356
-
with app.open_instance_resource(app.config["FHOST_UPLOAD_BLACKLIST"], "r") as bl:
357
-
check = addr.lstrip("::ffff:")
358
-
for l in bl.readlines():
359
-
if not l.startswith("#"):
360
-
if check == l.rstrip():
361
-
return True
362
-
363
-
return False
364
-
365
497
"""
366
498
requested_expiration can be:
367
499
- None, to use the longest allowed file lifespan
···
371
503
Any value greater that the longest allowed file lifespan will be rounded down to that
372
504
value.
373
505
"""
374
-
def store_file(f, requested_expiration: typing.Optional[int], addr, ua, secret: bool):
375
-
if in_upload_bl(addr):
376
-
return "Your host is blocked from uploading files.\n", 451
377
-
506
+
def store_file(f, requested_expiration: typing.Optional[int], addr, ua, secret: bool):
378
507
sf, isnew = File.store(f, requested_expiration, addr, ua, secret)
379
508
380
509
response = make_response(sf.geturl())
···
491
620
@app.route("/", methods=["GET", "POST"])
492
621
def fhost():
493
622
if request.method == "POST":
623
+
for flt in RequestFilter.query.all():
624
+
if flt.check_request(request):
625
+
abort(403, flt.reason)
626
+
494
627
sf = None
495
628
secret = "secret" in request.form
629
+
addr = ipaddress.ip_address(request.remote_addr)
630
+
if type(addr) is ipaddress.IPv6Address:
631
+
addr = addr.ipv4_mapped or addr
496
632
497
633
if "file" in request.files:
498
634
try:
···
500
636
return store_file(
501
637
request.files["file"],
502
638
int(request.form["expires"]),
503
-
request.remote_addr,
639
+
addr,
504
640
request.user_agent.string,
505
641
secret
506
642
)
···
512
648
return store_file(
513
649
request.files["file"],
514
650
None,
515
-
request.remote_addr,
651
+
addr,
516
652
request.user_agent.string,
517
653
secret
518
654
)
519
655
elif "url" in request.form:
520
656
return store_url(
521
657
request.form["url"],
522
-
request.remote_addr,
658
+
addr,
523
659
request.user_agent.string,
524
660
secret
525
661
)
···
538
674
539
675
@app.errorhandler(400)
540
676
@app.errorhandler(401)
677
+
@app.errorhandler(403)
541
678
@app.errorhandler(404)
542
679
@app.errorhandler(411)
543
680
@app.errorhandler(413)
···
546
683
@app.errorhandler(451)
547
684
def ehandler(e):
548
685
try:
549
-
return render_template(f"{e.code}.html", id=id, request=request), e.code
686
+
return render_template(f"{e.code}.html", id=id, request=request, description=e.description), e.code
550
687
except TemplateNotFound:
551
688
return "Segmentation fault\n", e.code
552
689
-24
instance/config.example.py
-24
instance/config.example.py
···
139
139
"text/x-diff" : ".diff",
140
140
}
141
141
142
-
143
-
# Control which files aren't allowed to be uploaded
144
-
#
145
-
# Certain kinds of files are never accepted. If the file claims to be one of
146
-
# these types of files, or if we look at the contents of the file and it looks
147
-
# like one of these filetypes, then we reject the file outright with a 415
148
-
# UNSUPPORTED MEDIA EXCEPTION
149
-
FHOST_MIME_BLACKLIST = [
150
-
"application/x-dosexec",
151
-
"application/java-archive",
152
-
"application/java-vm"
153
-
]
154
-
155
-
156
-
# A list of IP addresses which are blacklisted from uploading files
157
-
#
158
-
# Can be set to the path of a file with an IP address on each line. The file
159
-
# can also include comment lines using a pound sign (#). Paths are resolved
160
-
# relative to the instance/ directory.
161
-
#
162
-
# If this is set to None, then no IP blacklist will be consulted.
163
-
FHOST_UPLOAD_BLACKLIST = None
164
-
165
-
166
142
# Enables support for detecting NSFW images
167
143
#
168
144
# Consult README.md for additional dependencies before setting to True
+80
migrations/versions/5cda1743b92d_add_request_filters.py
+80
migrations/versions/5cda1743b92d_add_request_filters.py
···
1
+
"""Add request filters
2
+
3
+
Revision ID: 5cda1743b92d
4
+
Revises: dd0766afb7d2
5
+
Create Date: 2024-09-27 12:13:16.845981
6
+
7
+
"""
8
+
9
+
# revision identifiers, used by Alembic.
10
+
revision = '5cda1743b92d'
11
+
down_revision = 'dd0766afb7d2'
12
+
13
+
from alembic import op
14
+
import sqlalchemy as sa
15
+
from sqlalchemy.ext.automap import automap_base
16
+
from sqlalchemy.orm import Session
17
+
from flask import current_app
18
+
import ipaddress
19
+
20
+
Base = automap_base()
21
+
22
+
def upgrade():
23
+
# ### commands auto generated by Alembic - please adjust! ###
24
+
op.create_table('request_filter',
25
+
sa.Column('id', sa.Integer(), nullable=False),
26
+
sa.Column('type', sa.String(length=20), nullable=False),
27
+
sa.Column('comment', sa.UnicodeText(), nullable=True),
28
+
sa.Column('addr', sa.LargeBinary(length=16), nullable=True),
29
+
sa.Column('net', sa.Text(), nullable=True),
30
+
sa.Column('regex', sa.UnicodeText(), nullable=True),
31
+
sa.PrimaryKeyConstraint('id'),
32
+
sa.UniqueConstraint('addr')
33
+
)
34
+
with op.batch_alter_table('request_filter', schema=None) as batch_op:
35
+
batch_op.create_index(batch_op.f('ix_request_filter_type'), ['type'], unique=False)
36
+
37
+
# ### end Alembic commands ###
38
+
39
+
bind = op.get_bind()
40
+
Base.prepare(autoload_with=bind)
41
+
RequestFilter = Base.classes.request_filter
42
+
session = Session(bind=bind)
43
+
44
+
if "FHOST_UPLOAD_BLACKLIST" in current_app.config:
45
+
if current_app.config["FHOST_UPLOAD_BLACKLIST"]:
46
+
with current_app.open_instance_resource(current_app.config["FHOST_UPLOAD_BLACKLIST"], "r") as bl:
47
+
for l in bl.readlines():
48
+
if not l.startswith("#"):
49
+
l = l.strip()
50
+
if l.endswith(":"):
51
+
# old implementation uses str.startswith,
52
+
# which does not translate to networks
53
+
current_app.logger.warning(f"Ignored address: {l}")
54
+
continue
55
+
56
+
flt = RequestFilter(type="addr", addr=ipaddress.ip_address(l).packed)
57
+
session.add(flt)
58
+
59
+
if "FHOST_MIME_BLACKLIST" in current_app.config:
60
+
for mime in current_app.config["FHOST_MIME_BLACKLIST"]:
61
+
flt = RequestFilter(type="mime", regex=mime)
62
+
session.add(flt)
63
+
64
+
session.commit()
65
+
66
+
w = "Entries in your host and MIME blacklists have been migrated to " \
67
+
"request filters and stored in the databaes, where possible. " \
68
+
"The corresponding files and config options may now be deleted. " \
69
+
"Note that you may have to manually restore them if you wish to " \
70
+
"revert this with a db downgrade operation."
71
+
current_app.logger.warning(w)
72
+
73
+
74
+
def downgrade():
75
+
# ### commands auto generated by Alembic - please adjust! ###
76
+
with op.batch_alter_table('request_filter', schema=None) as batch_op:
77
+
batch_op.drop_index(batch_op.f('ix_request_filter_type'))
78
+
79
+
op.drop_table('request_filter')
80
+
# ### end Alembic commands ###
+78
migrations/versions/d9a53a28ba54_change_file_addr_to_ipaddress_type.py
+78
migrations/versions/d9a53a28ba54_change_file_addr_to_ipaddress_type.py
···
1
+
"""Change File.addr to IPAddress type
2
+
3
+
Revision ID: d9a53a28ba54
4
+
Revises: 5cda1743b92d
5
+
Create Date: 2024-09-27 14:03:06.764764
6
+
7
+
"""
8
+
9
+
# revision identifiers, used by Alembic.
10
+
revision = 'd9a53a28ba54'
11
+
down_revision = '5cda1743b92d'
12
+
13
+
from alembic import op
14
+
import sqlalchemy as sa
15
+
from sqlalchemy.ext.automap import automap_base
16
+
from sqlalchemy.orm import Session
17
+
from flask import current_app
18
+
import ipaddress
19
+
20
+
Base = automap_base()
21
+
22
+
23
+
def upgrade():
24
+
with op.batch_alter_table('file', schema=None) as batch_op:
25
+
batch_op.add_column(sa.Column('addr_tmp', sa.LargeBinary(16),
26
+
nullable=True))
27
+
28
+
bind = op.get_bind()
29
+
Base.prepare(autoload_with=bind)
30
+
File = Base.classes.file
31
+
session = Session(bind=bind)
32
+
33
+
updates = []
34
+
stmt = sa.select(File).where(sa.not_(File.addr == None))
35
+
for f in session.scalars(stmt.execution_options(yield_per=1000)):
36
+
addr = ipaddress.ip_address(f.addr)
37
+
if type(addr) is ipaddress.IPv6Address:
38
+
addr = addr.ipv4_mapped or addr
39
+
40
+
updates.append({
41
+
"id": f.id,
42
+
"addr_tmp": addr.packed
43
+
})
44
+
session.execute(sa.update(File), updates)
45
+
46
+
with op.batch_alter_table('file', schema=None) as batch_op:
47
+
batch_op.drop_column('addr')
48
+
batch_op.alter_column('addr_tmp', new_column_name='addr')
49
+
50
+
51
+
def downgrade():
52
+
with op.batch_alter_table('file', schema=None) as batch_op:
53
+
batch_op.add_column(sa.Column('addr_tmp', sa.UnicodeText,
54
+
nullable=True))
55
+
56
+
bind = op.get_bind()
57
+
Base.prepare(autoload_with=bind)
58
+
File = Base.classes.file
59
+
session = Session(bind=bind)
60
+
61
+
updates = []
62
+
stmt = sa.select(File).where(sa.not_(File.addr == None))
63
+
for f in session.scalars(stmt.execution_options(yield_per=1000)):
64
+
addr = ipaddress.ip_address(f.addr)
65
+
if type(addr) is ipaddress.IPv6Address:
66
+
addr = addr.ipv4_mapped or addr
67
+
68
+
updates.append({
69
+
"id": f.id,
70
+
"addr_tmp": addr.compressed
71
+
})
72
+
73
+
session.execute(sa.update(File), updates)
74
+
75
+
with op.batch_alter_table('file', schema=None) as batch_op:
76
+
batch_op.drop_column('addr')
77
+
batch_op.alter_column('addr_tmp', new_column_name='addr')
78
+
+28
-23
mod.py
+28
-23
mod.py
···
11
11
from textual import log
12
12
from rich.text import Text
13
13
from jinja2.filters import do_filesizeformat
14
+
import ipaddress
14
15
15
-
from fhost import db, File, su, app as fhost_app, in_upload_bl
16
+
from fhost import db, File, AddrFilter, su, app as fhost_app
16
17
from modui import *
17
18
18
19
fhost_app.app_context().push()
···
57
58
if self.current_file:
58
59
match fcol:
59
60
case 1: self.finput.value = ""
60
-
case 2: self.finput.value = self.current_file.addr
61
+
case 2: self.finput.value = self.current_file.addr.compressed
61
62
case 3: self.finput.value = self.current_file.mime
62
63
case 4: self.finput.value = self.current_file.ext
63
64
case 5: self.finput.value = self.current_file.ua or ""
···
72
73
case 1:
73
74
try: ftable.query = ftable.base_query.filter(File.id == su.debase(message.value))
74
75
except ValueError: pass
75
-
case 2: ftable.query = ftable.base_query.filter(File.addr.like(message.value))
76
+
case 2:
77
+
try:
78
+
addr = ipaddress.ip_address(message.value)
79
+
if type(addr) is ipaddress.IPv6Address:
80
+
addr = addr.ipv4_mapped or addr
81
+
q = ftable.base_query.filter(File.addr == addr)
82
+
ftable.query = q
83
+
except ValueError: pass
76
84
case 3: ftable.query = ftable.base_query.filter(File.mime.like(message.value))
77
85
case 4: ftable.query = ftable.base_query.filter(File.ext.like(message.value))
78
86
case 5: ftable.query = ftable.base_query.filter(File.ua.like(message.value))
···
88
96
89
97
def action_ban_ip(self, nuke: bool) -> None:
90
98
if self.current_file:
91
-
if not fhost_app.config["FHOST_UPLOAD_BLACKLIST"]:
92
-
self.mount(Notification("Failed: FHOST_UPLOAD_BLACKLIST not set!"))
93
-
return
99
+
if AddrFilter.query.filter(AddrFilter.addr ==
100
+
self.current_file.addr).scalar():
101
+
txt = f"{self.current_file.addr.compressed} is already banned"
94
102
else:
95
-
if in_upload_bl(self.current_file.addr):
96
-
txt = f"{self.current_file.addr} is already banned"
97
-
else:
98
-
with fhost_app.open_instance_resource(fhost_app.config["FHOST_UPLOAD_BLACKLIST"], "a") as bl:
99
-
print(self.current_file.addr.lstrip("::ffff:"), file=bl)
100
-
txt = f"Banned {self.current_file.addr}"
103
+
db.session.add(AddrFilter(self.current_file.addr))
104
+
db.session.commit()
105
+
txt = f"Banned {self.current_file.addr.compressed}"
101
106
102
-
if nuke:
103
-
tsize = 0
104
-
trm = 0
105
-
for f in File.query.filter(File.addr == self.current_file.addr):
106
-
if f.getpath().is_file():
107
-
tsize += f.size or f.getpath().stat().st_size
108
-
trm += 1
109
-
f.delete(True)
110
-
db.session.commit()
111
-
txt += f", removed {trm} {'files' if trm != 1 else 'file'} totaling {do_filesizeformat(tsize, True)}"
107
+
if nuke:
108
+
tsize = 0
109
+
trm = 0
110
+
for f in File.query.filter(File.addr == self.current_file.addr):
111
+
if f.getpath().is_file():
112
+
tsize += f.size or f.getpath().stat().st_size
113
+
trm += 1
114
+
f.delete(True)
115
+
db.session.commit()
116
+
txt += f", removed {trm} {'files' if trm != 1 else 'file'} totaling {do_filesizeformat(tsize, True)}"
112
117
self.mount(Notification(txt))
113
118
self._refresh_layout()
114
119
ftable = self.query_one("#ftable")
···
252
257
("File size:", do_filesizeformat(f.size, True)),
253
258
("MIME type:", f.mime),
254
259
("SHA256 checksum:", f.sha256),
255
-
("Uploaded by:", Text(f.addr)),
260
+
("Uploaded by:", Text(f.addr.compressed)),
256
261
("User agent:", Text(f.ua or "")),
257
262
("Management token:", f.mgmt_token),
258
263
("Secret:", f.secret),
+1
requirements.txt
+1
requirements.txt
+1
templates/403.html
+1
templates/403.html
···
1
+
{{ description if description else "Your host is banned." }}