Added additional security for attachment handling (#300)

This commit is contained in:
Chris Caron 2020-09-21 11:26:02 -04:00 committed by GitHub
parent 7187af5991
commit fcd81160be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 233 additions and 47 deletions

View File

@ -58,13 +58,15 @@ class Apprise(object):
"""
def __init__(self, servers=None, asset=None, debug=False):
def __init__(self, servers=None, asset=None, location=None, debug=False):
"""
Loads a set of server urls while applying the Asset() module to each
if specified.
If no asset is provided, then the default asset is used.
Optionally specify a global ContentLocation for a more strict means
of handling Attachments.
"""
# Initialize a server list of URLs
@ -87,6 +89,11 @@ class Apprise(object):
# Set our debug flag
self.debug = debug
# Store our hosting location for optional strict rule handling
# of Attachments. Setting this to None removes any attachment
# restrictions.
self.location = location
@staticmethod
def instantiate(url, asset=None, tag=None, suppress_exceptions=True):
"""
@ -325,7 +332,8 @@ class Apprise(object):
# Prepare attachments if required
if attach is not None and not isinstance(attach, AppriseAttachment):
try:
attach = AppriseAttachment(attach, asset=self.asset)
attach = AppriseAttachment(
attach, asset=self.asset, location=self.location)
except TypeError:
# bad attachments

View File

@ -29,6 +29,8 @@ from . import attachment
from . import URLBase
from .AppriseAsset import AppriseAsset
from .logger import logger
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .utils import GET_SCHEMA_RE
@ -38,7 +40,8 @@ class AppriseAttachment(object):
"""
def __init__(self, paths=None, asset=None, cache=True, **kwargs):
def __init__(self, paths=None, asset=None, cache=True, location=None,
**kwargs):
"""
Loads all of the paths/urls specified (if any).
@ -59,6 +62,25 @@ class AppriseAttachment(object):
It's also worth nothing that the cache value is only set to elements
that are not already of subclass AttachBase()
Optionally set your current ContentLocation in the location argument.
This is used to further handle attachments. The rules are as follows:
- INACCESSIBLE: You simply have disabled use of the object; no
attachments will be retrieved/handled.
- HOSTED: You are hosting an attachment service for others.
In these circumstances all attachments that are LOCAL
based (such as file://) will not be allowed.
- LOCAL: The least restrictive mode as local files can be
referenced in addition to hosted.
In all both HOSTED and LOCAL modes, INACCESSIBLE attachment types will
continue to be inaccessible. However if you set this field (location)
to None (it's default value) the attachment location category will not
be tested in any way (all attachment types will be allowed).
The location field is also a global option that can be set when
initializing the Apprise object.
"""
# Initialize our attachment listings
@ -71,6 +93,15 @@ class AppriseAttachment(object):
self.asset = \
asset if isinstance(asset, AppriseAsset) else AppriseAsset()
if location is not None and location not in CONTENT_LOCATIONS:
msg = "An invalid Attachment location ({}) was specified." \
.format(location)
logger.warning(msg)
raise TypeError(msg)
# Store our location
self.location = location
# Now parse any paths specified
if paths is not None:
# Store our path(s)
@ -123,26 +154,45 @@ class AppriseAttachment(object):
# Iterate over our attachments
for _attachment in attachments:
if isinstance(_attachment, attachment.AttachBase):
# Go ahead and just add our attachment into our list
self.attachments.append(_attachment)
if self.location == ContentLocation.INACCESSIBLE:
logger.warning(
"Attachments are disabled; ignoring {}"
.format(_attachment))
return_status = False
continue
elif not isinstance(_attachment, six.string_types):
if isinstance(_attachment, six.string_types):
logger.debug("Loading attachment: {}".format(_attachment))
# Instantiate ourselves an object, this function throws or
# returns None if it fails
instance = AppriseAttachment.instantiate(
_attachment, asset=asset, cache=cache)
if not isinstance(instance, attachment.AttachBase):
return_status = False
continue
elif not isinstance(_attachment, attachment.AttachBase):
logger.warning(
"An invalid attachment (type={}) was specified.".format(
type(_attachment)))
return_status = False
continue
logger.debug("Loading attachment: {}".format(_attachment))
else:
# our entry is of type AttachBase, so just go ahead and point
# our instance to it for some post processing below
instance = _attachment
# Instantiate ourselves an object, this function throws or
# returns None if it fails
instance = AppriseAttachment.instantiate(
_attachment, asset=asset, cache=cache)
if not isinstance(instance, attachment.AttachBase):
# Apply some simple logic if our location flag is set
if self.location and ((
self.location == ContentLocation.HOSTED
and instance.location != ContentLocation.HOSTED)
or instance.location == ContentLocation.INACCESSIBLE):
logger.warning(
"Attachment was disallowed due to accessibility "
"restrictions ({}->{}): {}".format(
self.location, instance.location,
instance.url(privacy=True)))
return_status = False
continue

View File

@ -41,8 +41,10 @@ from .common import OverflowMode
from .common import OVERFLOW_MODES
from .common import ConfigFormat
from .common import CONFIG_FORMATS
from .common import ConfigIncludeMode
from .common import CONFIG_INCLUDE_MODES
from .common import ContentIncludeMode
from .common import CONTENT_INCLUDE_MODES
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .URLBase import URLBase
from .URLBase import PrivacyMode
@ -69,6 +71,7 @@ __all__ = [
'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode',
'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES',
'ConfigFormat', 'CONFIG_FORMATS',
'ConfigIncludeMode', 'CONFIG_INCLUDE_MODES',
'ContentIncludeMode', 'CONTENT_INCLUDE_MODES',
'ContentLocation', 'CONTENT_LOCATIONS',
'PrivacyMode',
]

View File

@ -28,6 +28,7 @@ import time
import mimetypes
from ..URLBase import URLBase
from ..utils import parse_bool
from ..common import ContentLocation
from ..AppriseLocale import gettext_lazy as _
@ -62,6 +63,11 @@ class AttachBase(URLBase):
# 5 MB = 5242880 bytes
max_file_size = 5242880
# By default all attachments types are inaccessible.
# Developers of items identified in the attachment plugin directory
# are requried to set a location
location = ContentLocation.INACCESSIBLE
# Here is where we define all of the arguments we accept on the url
# such as: schema://whatever/?overflow=upstream&format=text
# These act the same way as tokens except they are optional and/or

View File

@ -26,6 +26,7 @@
import re
import os
from .AttachBase import AttachBase
from ..common import ContentLocation
from ..AppriseLocale import gettext_lazy as _
@ -40,6 +41,10 @@ class AttachFile(AttachBase):
# The default protocol
protocol = 'file'
# Content is local to the same location as the apprise instance
# being called (server-side)
location = ContentLocation.LOCAL
def __init__(self, path, **kwargs):
"""
Initialize Local File Attachment Object
@ -81,6 +86,10 @@ class AttachFile(AttachBase):
validate it.
"""
if self.location == ContentLocation.INACCESSIBLE:
# our content is inaccessible
return False
# Ensure any existing content set has been invalidated
self.invalidate()

View File

@ -29,6 +29,7 @@ import six
import requests
from tempfile import NamedTemporaryFile
from .AttachBase import AttachBase
from ..common import ContentLocation
from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _
@ -50,6 +51,9 @@ class AttachHTTP(AttachBase):
# The number of bytes in memory to read from the remote source at a time
chunk_size = 8192
# Web based requests are remote/external to our current location
location = ContentLocation.HOSTED
def __init__(self, headers=None, **kwargs):
"""
Initialize HTTP Object
@ -86,6 +90,10 @@ class AttachHTTP(AttachBase):
Perform retrieval of the configuration based on the specified request
"""
if self.location == ContentLocation.INACCESSIBLE:
# our content is inaccessible
return False
# Ensure any existing content set has been invalidated
self.invalidate()

View File

@ -39,6 +39,7 @@ from . import AppriseConfig
from .utils import parse_list
from .common import NOTIFY_TYPES
from .common import NOTIFY_FORMATS
from .common import ContentLocation
from .logger import logger
from . import __title__
@ -224,8 +225,12 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
# Prepare our asset
asset = AppriseAsset(
# Our body format
body_format=input_format,
# Set the theme
theme=theme,
# Async mode is only used for Python v3+ and allows a user to send
# all of their notifications asyncronously. This was made an option
# incase there are problems in the future where it's better that
@ -234,7 +239,7 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
)
# Create our Apprise object
a = Apprise(asset=asset, debug=debug)
a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL)
# Load our configuration if no URLs or specified configuration was
# identified on the command line

View File

@ -130,28 +130,58 @@ CONFIG_FORMATS = (
)
class ConfigIncludeMode(object):
class ContentIncludeMode(object):
"""
The different Cofiguration inclusion modes. All Configuration
plugins will have one of these associated with it.
The different Content inclusion modes. All content based plugins will
have one of these associated with it.
"""
# - Configuration inclusion of same type only; hence a file:// can include
# - Content inclusion of same type only; hence a file:// can include
# a file://
# - Cross file inclusion is not allowed unless insecure_includes (a flag)
# is set to True. In these cases STRICT acts as type ALWAYS
STRICT = 'strict'
# This configuration type can never be included
# This content type can never be included
NEVER = 'never'
# File configuration can always be included
# This content can always be included
ALWAYS = 'always'
CONFIG_INCLUDE_MODES = (
ConfigIncludeMode.STRICT,
ConfigIncludeMode.NEVER,
ConfigIncludeMode.ALWAYS,
CONTENT_INCLUDE_MODES = (
ContentIncludeMode.STRICT,
ContentIncludeMode.NEVER,
ContentIncludeMode.ALWAYS,
)
class ContentLocation(object):
"""
This is primarily used for handling file attachments. The idea is
to track the source of the attachment itself. We don't want
remote calls to a server to access local attachments for example.
By knowing the attachment type and cross-associating it with how
we plan on accessing the content, we can make a judgement call
(for security reasons) if we will allow it.
Obviously local uses of apprise can access both local and remote
type files.
"""
# Content is located locally (on the same server as apprise)
LOCAL = 'local'
# Content is located in a remote location
HOSTED = 'hosted'
# Content is inaccessible
INACCESSIBLE = 'n/a'
CONTENT_LOCATIONS = (
ContentLocation.LOCAL,
ContentLocation.HOSTED,
ContentLocation.INACCESSIBLE,
)
# This is a reserved tag that is automatically assigned to every

View File

@ -34,7 +34,7 @@ from ..AppriseAsset import AppriseAsset
from ..URLBase import URLBase
from ..common import ConfigFormat
from ..common import CONFIG_FORMATS
from ..common import ConfigIncludeMode
from ..common import ContentIncludeMode
from ..utils import GET_SCHEMA_RE
from ..utils import parse_list
from ..utils import parse_bool
@ -65,7 +65,7 @@ class ConfigBase(URLBase):
# By default all configuration is not includable using the 'include'
# line found in configuration files.
allow_cross_includes = ConfigIncludeMode.NEVER
allow_cross_includes = ContentIncludeMode.NEVER
# the config path manages the handling of relative include
config_path = os.getcwd()
@ -240,11 +240,11 @@ class ConfigBase(URLBase):
# Handle cross inclusion based on allow_cross_includes rules
if (SCHEMA_MAP[schema].allow_cross_includes ==
ConfigIncludeMode.STRICT
ContentIncludeMode.STRICT
and schema not in self.schemas()
and not self.insecure_includes) or \
SCHEMA_MAP[schema].allow_cross_includes == \
ConfigIncludeMode.NEVER:
ContentIncludeMode.NEVER:
# Prevent the loading if insecure base protocols
ConfigBase.logger.warning(

View File

@ -28,7 +28,7 @@ import io
import os
from .ConfigBase import ConfigBase
from ..common import ConfigFormat
from ..common import ConfigIncludeMode
from ..common import ContentIncludeMode
from ..AppriseLocale import gettext_lazy as _
@ -44,7 +44,7 @@ class ConfigFile(ConfigBase):
protocol = 'file'
# Configuration file inclusion can only be of the same type
allow_cross_includes = ConfigIncludeMode.STRICT
allow_cross_includes = ContentIncludeMode.STRICT
def __init__(self, path, **kwargs):
"""

View File

@ -28,7 +28,7 @@ import six
import requests
from .ConfigBase import ConfigBase
from ..common import ConfigFormat
from ..common import ConfigIncludeMode
from ..common import ContentIncludeMode
from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _
@ -66,7 +66,7 @@ class ConfigHTTP(ConfigBase):
max_error_buffer_size = 2048
# Configuration file inclusion can always include this type
allow_cross_includes = ConfigIncludeMode.ALWAYS
allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, headers=None, **kwargs):
"""

View File

@ -33,6 +33,7 @@ from apprise.AppriseAsset import AppriseAsset
from apprise.attachment.AttachBase import AttachBase
from apprise.attachment import SCHEMA_MAP as ATTACH_SCHEMA_MAP
from apprise.attachment import __load_matrix
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output
import logging
@ -183,11 +184,6 @@ def test_apprise_attachment():
# Reset our object
aa.clear()
# if instantiating attachments from the class, it will throw a TypeError
# if attachments couldn't be loaded
with pytest.raises(TypeError):
AppriseAttachment('garbage://')
# Garbage in produces garbage out
assert aa.add(None) is False
assert aa.add(object()) is False
@ -210,6 +206,39 @@ def test_apprise_attachment():
# length remains unchanged
assert len(aa) == 0
# if instantiating attachments from the class, it will throw a TypeError
# if attachments couldn't be loaded
with pytest.raises(TypeError):
AppriseAttachment('garbage://')
# Load our other attachment types
aa = AppriseAttachment(location=ContentLocation.LOCAL)
# Hosted type won't allow us to import files
aa = AppriseAttachment(location=ContentLocation.HOSTED)
assert len(aa) == 0
# Add our attachments defined a the head of this function
aa.add(attachments)
# Our length is still zero because we can't import files in
# a hosted environment
assert len(aa) == 0
# Inaccessible type prevents the adding of new stuff
aa = AppriseAttachment(location=ContentLocation.INACCESSIBLE)
assert len(aa) == 0
# Add our attachments defined a the head of this function
aa.add(attachments)
# Our length is still zero
assert len(aa) == 0
with pytest.raises(TypeError):
# Invalid location specified
AppriseAttachment(location="invalid")
# test cases when file simply doesn't exist
aa = AppriseAttachment('file://non-existant-file.png')
# Our length is still 1

View File

@ -30,7 +30,7 @@ import mock
import pytest
from apprise import NotifyFormat
from apprise import ConfigFormat
from apprise import ConfigIncludeMode
from apprise import ContentIncludeMode
from apprise.Apprise import Apprise
from apprise.AppriseConfig import AppriseConfig
from apprise.AppriseAsset import AppriseAsset
@ -417,7 +417,7 @@ def test_apprise_config_instantiate():
class BadConfig(ConfigBase):
# always allow incusion
allow_cross_includes = ConfigIncludeMode.ALWAYS
allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, **kwargs):
super(BadConfig, self).__init__(**kwargs)
@ -450,7 +450,7 @@ def test_invalid_apprise_config(tmpdir):
class BadConfig(ConfigBase):
# always allow incusion
allow_cross_includes = ConfigIncludeMode.ALWAYS
allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, **kwargs):
super(BadConfig, self).__init__(**kwargs)
@ -699,7 +699,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'always'
# Always type
allow_cross_includes = ConfigIncludeMode.ALWAYS
allow_cross_includes = ContentIncludeMode.ALWAYS
class ConfigCrossPostStrict(ConfigFile):
"""
@ -712,7 +712,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'strict'
# Always type
allow_cross_includes = ConfigIncludeMode.STRICT
allow_cross_includes = ContentIncludeMode.STRICT
class ConfigCrossPostNever(ConfigFile):
"""
@ -725,7 +725,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'never'
# Always type
allow_cross_includes = ConfigIncludeMode.NEVER
allow_cross_includes = ContentIncludeMode.NEVER
# store our entries
CONFIG_SCHEMA_MAP['never'] = ConfigCrossPostNever

View File

@ -31,6 +31,7 @@ from os.path import join
from apprise.attachment.AttachBase import AttachBase
from apprise.attachment.AttachFile import AttachFile
from apprise import AppriseAttachment
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output
import logging
@ -102,6 +103,23 @@ def test_attach_file():
assert re.search(r'[?&]mime=', response.url()) is None
assert re.search(r'[?&]name=', response.url()) is None
# Test case where location is simply set to INACCESSIBLE
# Below is a bad example, but it proves the section of code properly works.
# Ideally a server admin may wish to just disable all File based
# attachments entirely. In this case, they simply just need to change the
# global singleton at the start of their program like:
#
# import apprise
# apprise.attachment.AttachFile.location = \
# apprise.ContentLocation.INACCESSIBLE
#
response = AppriseAttachment.instantiate(path)
assert isinstance(response, AttachFile)
response.location = ContentLocation.INACCESSIBLE
assert response.path is None
# Downloads just don't work period
assert response.download() is False
# File handling (even if image is set to maxium allowable)
response = AppriseAttachment.instantiate(path)
assert isinstance(response, AttachFile)
@ -179,3 +197,7 @@ def test_attach_file():
# We will match on mime type now (%2F = /)
assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I)
assert re.search(r'[?&]name=test\.jpeg', response.url(), re.I)
# Test hosted configuration and that we can't add a valid file
aa = AppriseAttachment(location=ContentLocation.HOSTED)
assert aa.add(path) is False

View File

@ -35,6 +35,7 @@ from apprise.attachment.AttachHTTP import AttachHTTP
from apprise import AppriseAttachment
from apprise.plugins.NotifyBase import NotifyBase
from apprise.plugins import SCHEMA_MAP
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output
import logging
@ -228,6 +229,21 @@ def test_attach_http(mock_get):
assert attachment
assert len(attachment) == getsize(path)
# Test case where location is simply set to INACCESSIBLE
# Below is a bad example, but it proves the section of code properly works.
# Ideally a server admin may wish to just disable all HTTP based
# attachments entirely. In this case, they simply just need to change the
# global singleton at the start of their program like:
#
# import apprise
# apprise.attachment.AttachHTTP.location = \
# apprise.ContentLocation.INACCESSIBLE
attachment = AttachHTTP(**results)
attachment.location = ContentLocation.INACCESSIBLE
assert attachment.path is None
# Downloads just don't work period
assert attachment.download() is False
# No path specified
# No Content-Disposition specified
# No filename (because no path)