[mirror of https://git.0x0.st/mia/0x0] No-bullshit file hosting and URL shortening service https://0x0.st

Implement request filters

This moves preexisting blacklists to the database, and adds the
following filter types:

* IP address
* IP network
* MIME type
* User agent

In addition, IP address handling is now done with the ipaddress
module.

+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
··· 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
··· 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
··· 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
··· 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
··· 7 7 Flask 8 8 flask_sqlalchemy 9 9 python_magic 10 + ipaddress 10 11 11 12 # vscan 12 13 clamd
+1
templates/403.html
··· 1 + {{ description if description else "Your host is banned." }}