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 Loads a set of server urls while applying the Asset() module to each
if specified. if specified.
If no asset is provided, then the default asset is used. 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 # Initialize a server list of URLs
@ -87,6 +89,11 @@ class Apprise(object):
# Set our debug flag # Set our debug flag
self.debug = debug 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 @staticmethod
def instantiate(url, asset=None, tag=None, suppress_exceptions=True): def instantiate(url, asset=None, tag=None, suppress_exceptions=True):
""" """
@ -325,7 +332,8 @@ class Apprise(object):
# Prepare attachments if required # Prepare attachments if required
if attach is not None and not isinstance(attach, AppriseAttachment): if attach is not None and not isinstance(attach, AppriseAttachment):
try: try:
attach = AppriseAttachment(attach, asset=self.asset) attach = AppriseAttachment(
attach, asset=self.asset, location=self.location)
except TypeError: except TypeError:
# bad attachments # bad attachments

View File

@ -29,6 +29,8 @@ from . import attachment
from . import URLBase from . import URLBase
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .logger import logger from .logger import logger
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .utils import GET_SCHEMA_RE 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). 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 It's also worth nothing that the cache value is only set to elements
that are not already of subclass AttachBase() 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 # Initialize our attachment listings
@ -71,6 +93,15 @@ class AppriseAttachment(object):
self.asset = \ self.asset = \
asset if isinstance(asset, AppriseAsset) else AppriseAsset() 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 # Now parse any paths specified
if paths is not None: if paths is not None:
# Store our path(s) # Store our path(s)
@ -123,26 +154,45 @@ class AppriseAttachment(object):
# Iterate over our attachments # Iterate over our attachments
for _attachment in attachments: for _attachment in attachments:
if self.location == ContentLocation.INACCESSIBLE:
if isinstance(_attachment, attachment.AttachBase): logger.warning(
# Go ahead and just add our attachment into our list "Attachments are disabled; ignoring {}"
self.attachments.append(_attachment) .format(_attachment))
return_status = False
continue 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( logger.warning(
"An invalid attachment (type={}) was specified.".format( "An invalid attachment (type={}) was specified.".format(
type(_attachment))) type(_attachment)))
return_status = False return_status = False
continue 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 # Apply some simple logic if our location flag is set
# returns None if it fails if self.location and ((
instance = AppriseAttachment.instantiate( self.location == ContentLocation.HOSTED
_attachment, asset=asset, cache=cache) and instance.location != ContentLocation.HOSTED)
if not isinstance(instance, attachment.AttachBase): 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 return_status = False
continue continue

View File

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

View File

@ -28,6 +28,7 @@ import time
import mimetypes import mimetypes
from ..URLBase import URLBase from ..URLBase import URLBase
from ..utils import parse_bool from ..utils import parse_bool
from ..common import ContentLocation
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
@ -62,6 +63,11 @@ class AttachBase(URLBase):
# 5 MB = 5242880 bytes # 5 MB = 5242880 bytes
max_file_size = 5242880 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 # Here is where we define all of the arguments we accept on the url
# such as: schema://whatever/?overflow=upstream&format=text # such as: schema://whatever/?overflow=upstream&format=text
# These act the same way as tokens except they are optional and/or # These act the same way as tokens except they are optional and/or

View File

@ -26,6 +26,7 @@
import re import re
import os import os
from .AttachBase import AttachBase from .AttachBase import AttachBase
from ..common import ContentLocation
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
@ -40,6 +41,10 @@ class AttachFile(AttachBase):
# The default protocol # The default protocol
protocol = 'file' 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): def __init__(self, path, **kwargs):
""" """
Initialize Local File Attachment Object Initialize Local File Attachment Object
@ -81,6 +86,10 @@ class AttachFile(AttachBase):
validate it. validate it.
""" """
if self.location == ContentLocation.INACCESSIBLE:
# our content is inaccessible
return False
# Ensure any existing content set has been invalidated # Ensure any existing content set has been invalidated
self.invalidate() self.invalidate()

View File

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

View File

@ -39,6 +39,7 @@ from . import AppriseConfig
from .utils import parse_list from .utils import parse_list
from .common import NOTIFY_TYPES from .common import NOTIFY_TYPES
from .common import NOTIFY_FORMATS from .common import NOTIFY_FORMATS
from .common import ContentLocation
from .logger import logger from .logger import logger
from . import __title__ from . import __title__
@ -224,8 +225,12 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
# Prepare our asset # Prepare our asset
asset = AppriseAsset( asset = AppriseAsset(
# Our body format
body_format=input_format, body_format=input_format,
# Set the theme
theme=theme, theme=theme,
# Async mode is only used for Python v3+ and allows a user to send # Async mode is only used for Python v3+ and allows a user to send
# all of their notifications asyncronously. This was made an option # all of their notifications asyncronously. This was made an option
# incase there are problems in the future where it's better that # 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 # 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 # Load our configuration if no URLs or specified configuration was
# identified on the command line # 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 The different Content inclusion modes. All content based plugins will
plugins will have one of these associated with it. 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:// # a file://
# - Cross file inclusion is not allowed unless insecure_includes (a flag) # - Cross file inclusion is not allowed unless insecure_includes (a flag)
# is set to True. In these cases STRICT acts as type ALWAYS # is set to True. In these cases STRICT acts as type ALWAYS
STRICT = 'strict' STRICT = 'strict'
# This configuration type can never be included # This content type can never be included
NEVER = 'never' NEVER = 'never'
# File configuration can always be included # This content can always be included
ALWAYS = 'always' ALWAYS = 'always'
CONFIG_INCLUDE_MODES = ( CONTENT_INCLUDE_MODES = (
ConfigIncludeMode.STRICT, ContentIncludeMode.STRICT,
ConfigIncludeMode.NEVER, ContentIncludeMode.NEVER,
ConfigIncludeMode.ALWAYS, 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 # 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 ..URLBase import URLBase
from ..common import ConfigFormat from ..common import ConfigFormat
from ..common import CONFIG_FORMATS from ..common import CONFIG_FORMATS
from ..common import ConfigIncludeMode from ..common import ContentIncludeMode
from ..utils import GET_SCHEMA_RE from ..utils import GET_SCHEMA_RE
from ..utils import parse_list from ..utils import parse_list
from ..utils import parse_bool from ..utils import parse_bool
@ -65,7 +65,7 @@ class ConfigBase(URLBase):
# By default all configuration is not includable using the 'include' # By default all configuration is not includable using the 'include'
# line found in configuration files. # line found in configuration files.
allow_cross_includes = ConfigIncludeMode.NEVER allow_cross_includes = ContentIncludeMode.NEVER
# the config path manages the handling of relative include # the config path manages the handling of relative include
config_path = os.getcwd() config_path = os.getcwd()
@ -240,11 +240,11 @@ class ConfigBase(URLBase):
# Handle cross inclusion based on allow_cross_includes rules # Handle cross inclusion based on allow_cross_includes rules
if (SCHEMA_MAP[schema].allow_cross_includes == if (SCHEMA_MAP[schema].allow_cross_includes ==
ConfigIncludeMode.STRICT ContentIncludeMode.STRICT
and schema not in self.schemas() and schema not in self.schemas()
and not self.insecure_includes) or \ and not self.insecure_includes) or \
SCHEMA_MAP[schema].allow_cross_includes == \ SCHEMA_MAP[schema].allow_cross_includes == \
ConfigIncludeMode.NEVER: ContentIncludeMode.NEVER:
# Prevent the loading if insecure base protocols # Prevent the loading if insecure base protocols
ConfigBase.logger.warning( ConfigBase.logger.warning(

View File

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

View File

@ -28,7 +28,7 @@ import six
import requests import requests
from .ConfigBase import ConfigBase from .ConfigBase import ConfigBase
from ..common import ConfigFormat from ..common import ConfigFormat
from ..common import ConfigIncludeMode from ..common import ContentIncludeMode
from ..URLBase import PrivacyMode from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
@ -66,7 +66,7 @@ class ConfigHTTP(ConfigBase):
max_error_buffer_size = 2048 max_error_buffer_size = 2048
# Configuration file inclusion can always include this type # Configuration file inclusion can always include this type
allow_cross_includes = ConfigIncludeMode.ALWAYS allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, headers=None, **kwargs): 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.AttachBase import AttachBase
from apprise.attachment import SCHEMA_MAP as ATTACH_SCHEMA_MAP from apprise.attachment import SCHEMA_MAP as ATTACH_SCHEMA_MAP
from apprise.attachment import __load_matrix from apprise.attachment import __load_matrix
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -183,11 +184,6 @@ def test_apprise_attachment():
# Reset our object # Reset our object
aa.clear() 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 # Garbage in produces garbage out
assert aa.add(None) is False assert aa.add(None) is False
assert aa.add(object()) is False assert aa.add(object()) is False
@ -210,6 +206,39 @@ def test_apprise_attachment():
# length remains unchanged # length remains unchanged
assert len(aa) == 0 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 # test cases when file simply doesn't exist
aa = AppriseAttachment('file://non-existant-file.png') aa = AppriseAttachment('file://non-existant-file.png')
# Our length is still 1 # Our length is still 1

View File

@ -30,7 +30,7 @@ import mock
import pytest import pytest
from apprise import NotifyFormat from apprise import NotifyFormat
from apprise import ConfigFormat from apprise import ConfigFormat
from apprise import ConfigIncludeMode from apprise import ContentIncludeMode
from apprise.Apprise import Apprise from apprise.Apprise import Apprise
from apprise.AppriseConfig import AppriseConfig from apprise.AppriseConfig import AppriseConfig
from apprise.AppriseAsset import AppriseAsset from apprise.AppriseAsset import AppriseAsset
@ -417,7 +417,7 @@ def test_apprise_config_instantiate():
class BadConfig(ConfigBase): class BadConfig(ConfigBase):
# always allow incusion # always allow incusion
allow_cross_includes = ConfigIncludeMode.ALWAYS allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(BadConfig, self).__init__(**kwargs) super(BadConfig, self).__init__(**kwargs)
@ -450,7 +450,7 @@ def test_invalid_apprise_config(tmpdir):
class BadConfig(ConfigBase): class BadConfig(ConfigBase):
# always allow incusion # always allow incusion
allow_cross_includes = ConfigIncludeMode.ALWAYS allow_cross_includes = ContentIncludeMode.ALWAYS
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(BadConfig, self).__init__(**kwargs) super(BadConfig, self).__init__(**kwargs)
@ -699,7 +699,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'always' protocol = 'always'
# Always type # Always type
allow_cross_includes = ConfigIncludeMode.ALWAYS allow_cross_includes = ContentIncludeMode.ALWAYS
class ConfigCrossPostStrict(ConfigFile): class ConfigCrossPostStrict(ConfigFile):
""" """
@ -712,7 +712,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'strict' protocol = 'strict'
# Always type # Always type
allow_cross_includes = ConfigIncludeMode.STRICT allow_cross_includes = ContentIncludeMode.STRICT
class ConfigCrossPostNever(ConfigFile): class ConfigCrossPostNever(ConfigFile):
""" """
@ -725,7 +725,7 @@ def test_recursive_config_inclusion(tmpdir):
protocol = 'never' protocol = 'never'
# Always type # Always type
allow_cross_includes = ConfigIncludeMode.NEVER allow_cross_includes = ContentIncludeMode.NEVER
# store our entries # store our entries
CONFIG_SCHEMA_MAP['never'] = ConfigCrossPostNever 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.AttachBase import AttachBase
from apprise.attachment.AttachFile import AttachFile from apprise.attachment.AttachFile import AttachFile
from apprise import AppriseAttachment from apprise import AppriseAttachment
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -102,6 +103,23 @@ def test_attach_file():
assert re.search(r'[?&]mime=', response.url()) is None assert re.search(r'[?&]mime=', response.url()) is None
assert re.search(r'[?&]name=', 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) # File handling (even if image is set to maxium allowable)
response = AppriseAttachment.instantiate(path) response = AppriseAttachment.instantiate(path)
assert isinstance(response, AttachFile) assert isinstance(response, AttachFile)
@ -179,3 +197,7 @@ def test_attach_file():
# We will match on mime type now (%2F = /) # We will match on mime type now (%2F = /)
assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I) assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I)
assert re.search(r'[?&]name=test\.jpeg', 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 import AppriseAttachment
from apprise.plugins.NotifyBase import NotifyBase from apprise.plugins.NotifyBase import NotifyBase
from apprise.plugins import SCHEMA_MAP from apprise.plugins import SCHEMA_MAP
from apprise.common import ContentLocation
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -228,6 +229,21 @@ def test_attach_http(mock_get):
assert attachment assert attachment
assert len(attachment) == getsize(path) 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 path specified
# No Content-Disposition specified # No Content-Disposition specified
# No filename (because no path) # No filename (because no path)