diff --git a/odoo/addons/base/tests/test_misc.py b/odoo/addons/base/tests/test_misc.py
index 0d139788c67d3db62e5522a6d9ece123e4000782..377e4a8329a4625d2cf0be5aa7fed87da8c7ab3b 100644
--- a/odoo/addons/base/tests/test_misc.py
+++ b/odoo/addons/base/tests/test_misc.py
@@ -3,9 +3,10 @@
 
 import datetime
 from dateutil.relativedelta import relativedelta
+import os.path
 import pytz
 
-from odoo.tools import misc, date_utils, merge_sequences, remove_accents
+from odoo.tools import config, misc, date_utils, file_open, file_path, merge_sequences, remove_accents
 from odoo.tests.common import TransactionCase, BaseCase
 
 
@@ -357,3 +358,96 @@ class TestRemoveAccents(BaseCase):
     def test_non_latin(self):
         self.assertEqual(remove_accents('العربية'), 'العربية')
         self.assertEqual(remove_accents('русский алфавит'), 'русскии алфавит')
+
+
+class TestAddonsFileAccess(BaseCase):
+
+    def assertCannotAccess(self, path, ExceptionType=FileNotFoundError, filter_ext=None):
+        with self.assertRaises(ExceptionType):
+            file_path(path, filter_ext=filter_ext)
+
+    def assertCanRead(self, path, needle='', mode='r', filter_ext=None):
+        with file_open(path, mode, filter_ext) as f:
+            self.assertIn(needle, f.read())
+
+    def assertCannotRead(self, path, ExceptionType=FileNotFoundError, filter_ext=None):
+        with self.assertRaises(ExceptionType):
+            file_open(path, filter_ext=filter_ext)
+
+    def test_file_path(self):
+        # absolute path
+        self.assertEqual(__file__, file_path(__file__))
+        self.assertEqual(__file__, file_path(__file__, filter_ext=None)) # means "no filter" too
+        self.assertEqual(__file__, file_path(__file__, filter_ext=('.py',)))
+
+        # directory target is ok
+        self.assertEqual(os.path.dirname(__file__), file_path(os.path.join(__file__, '..')))
+
+        # relative path
+        relpath = os.path.join(*(__file__.split(os.sep)[-3:])) # 'base/tests/test_misc.py'
+        self.assertEqual(__file__, file_path(relpath))
+        self.assertEqual(__file__, file_path(relpath, filter_ext=('.py',)))
+
+        # leading 'addons/' is ignored if present
+        relpath = os.path.join('addons', relpath) # 'addons/base/tests/test_misc.py'
+        self.assertEqual(__file__, file_path(relpath))
+
+        # files in root_path are allowed
+        self.assertTrue(file_path('tools/misc.py'))
+
+        # errors when outside addons_paths
+        self.assertCannotAccess('/doesnt/exist')
+        self.assertCannotAccess('/tmp')
+        self.assertCannotAccess('../../../../../../../../../tmp')
+        self.assertCannotAccess(os.path.join(__file__, '../../../../../'))
+
+        # data_dir is forbidden
+        self.assertCannotAccess(config['data_dir'])
+
+        # errors for illegal extensions
+        self.assertCannotAccess(__file__, ValueError, filter_ext=('.png',))
+        # file doesnt exist but has wrong extension
+        self.assertCannotAccess(__file__.replace('.py', '.foo'), ValueError, filter_ext=('.png',))
+
+    def test_file_open(self):
+        # The needle includes UTF8 so we test reading non-ASCII files at the same time.
+        # This depends on the system locale and is harder to unit test, but if you manage to run the
+        # test with a non-UTF8 locale (`LC_ALL=fr_FR.iso8859-1 python3...`) it should not crash ;-)
+        test_needle = "A needle with non-ascii bytes: ♥"
+
+        # absolute path
+        self.assertCanRead(__file__, test_needle)
+        self.assertCanRead(__file__, test_needle.encode(), mode='rb')
+        self.assertCanRead(__file__, test_needle.encode(), mode='rb', filter_ext=('.py',))
+
+        # directory target *is* an error
+        with self.assertRaises(FileNotFoundError):
+            file_open(os.path.join(__file__, '..'))
+
+        # relative path
+        relpath = os.path.join(*(__file__.split(os.sep)[-3:])) # 'base/tests/test_misc.py'
+        self.assertCanRead(relpath, test_needle)
+        self.assertCanRead(relpath, test_needle.encode(), mode='rb')
+        self.assertCanRead(relpath, test_needle.encode(), mode='rb', filter_ext=('.py',))
+
+        # leading 'addons/' is ignored if present
+        relpath = os.path.join('addons', relpath) # 'addons/base/tests/test_misc.py'
+        self.assertCanRead(relpath, test_needle)
+
+        # files in root_path are allowed
+        self.assertCanRead('tools/misc.py')
+
+        # errors when outside addons_paths
+        self.assertCannotRead('/doesnt/exist')
+        self.assertCannotRead('')
+        self.assertCannotRead('/tmp')
+        self.assertCannotRead('../../../../../../../../../tmp')
+        self.assertCannotRead(os.path.join(__file__, '../../../../../'))
+
+        # data_dir is forbidden
+        self.assertCannotRead(config['data_dir'])
+
+        # errors for illegal extensions
+        self.assertCannotRead(__file__, ValueError, filter_ext=('.png',))
+        # file doesnt exist but has wrong extension
+        self.assertCannotRead(__file__.replace('.py', '.foo'), ValueError, filter_ext=('.png',))
diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py
index 88413cbeb01e9fec27ccdbe91965e31b555b808f..134cb0f6aa12f1aa6399cf73062c860a209ab938 100644
--- a/odoo/tools/misc.py
+++ b/odoo/tools/misc.py
@@ -135,124 +135,74 @@ def exec_pg_command_pipe(name, *args):
 #----------------------------------------------------------
 # File paths
 #----------------------------------------------------------
-#file_path_root = os.getcwd()
-#file_path_addons = os.path.join(file_path_root, 'addons')
 
-def file_open(name, mode="r", subdir='addons', pathinfo=False):
-    """Open a file from the OpenERP root, using a subdir folder.
+def file_path(file_path, filter_ext=('',)):
+    """Verify that a file exists under a known `addons_path` directory and return its full path.
 
-    Example::
-
-    >>> file_open('hr/report/timesheer.xsl')
-    >>> file_open('addons/hr/report/timesheet.xsl')
+    Examples::
 
-    @param name name of the file
-    @param mode file open mode
-    @param subdir subdirectory
-    @param pathinfo if True returns tuple (fileobject, filepath)
+    >>> file_path('hr')
+    >>> file_path('hr/static/description/icon.png')
+    >>> file_path('hr/static/description/icon.png', filter_ext=('.png', '.jpg'))
 
-    @return fileobject if pathinfo is False else (fileobject, filepath)
+    :param str file_path: absolute file path, or relative path within any `addons_path` directory
+    :param list[str] filter_ext: optional list of supported extensions (lowercase, with leading dot)
+    :return: the absolute path to the file
+    :raise FileNotFoundError: if the file is not found under the known `addons_path` directories
+    :raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
     """
-    adps = odoo.addons.__path__
-    rtp = os.path.normcase(os.path.abspath(config['root_path']))
-
-    basename = name
-
-    if os.path.isabs(name):
-        # It is an absolute path
-        # Is it below 'addons_path' or 'root_path'?
-        name = os.path.normcase(os.path.normpath(name))
-        for root in adps + [rtp]:
-            root = os.path.normcase(os.path.normpath(root)) + os.sep
-            if name.startswith(root):
-                base = root.rstrip(os.sep)
-                name = name[len(base) + 1:]
-                break
-        else:
-            # It is outside the OpenERP root: skip zipfile lookup.
-            base, name = os.path.split(name)
-        return _fileopen(name, mode=mode, basedir=base, pathinfo=pathinfo, basename=basename)
-
-    if name.replace(os.sep, '/').startswith('addons/'):
-        subdir = 'addons'
-        name2 = name[7:]
-    elif subdir:
-        name = os.path.join(subdir, name)
-        if name.replace(os.sep, '/').startswith('addons/'):
-            subdir = 'addons'
-            name2 = name[7:]
-        else:
-            name2 = name
+    root_path = os.path.abspath(config['root_path'])
+    addons_paths = odoo.addons.__path__ + [root_path]
+    is_abs = os.path.isabs(file_path)
+    normalized_path = os.path.normpath(os.path.normcase(file_path))
 
-    # First, try to locate in addons_path
-    if subdir:
-        for adp in adps:
-            try:
-                return _fileopen(name2, mode=mode, basedir=adp,
-                                 pathinfo=pathinfo, basename=basename)
-            except IOError:
-                pass
+    if filter_ext and not normalized_path.lower().endswith(filter_ext):
+        raise ValueError("Unsupported file: " + file_path)
 
-    # Second, try to locate in root_path
-    return _fileopen(name, mode=mode, basedir=rtp, pathinfo=pathinfo, basename=basename)
+    # ignore leading 'addons/' if present, it's the final component of root_path, but
+    # may sometimes be included in relative paths
+    if normalized_path.startswith('addons' + os.sep):
+        normalized_path = normalized_path[7:]
 
+    for addons_dir in addons_paths:
+        # final path sep required to avoid partial match
+        parent_path = os.path.normpath(os.path.normcase(addons_dir)) + os.sep
+        fpath = (normalized_path if is_abs else
+                 os.path.normpath(os.path.normcase(os.path.join(parent_path, file_path))))
+        if fpath.startswith(parent_path) and os.path.exists(fpath):
+            return fpath
 
-def _fileopen(path, mode, basedir, pathinfo, basename=None):
-    name = os.path.normpath(os.path.normcase(os.path.join(basedir, path)))
+    raise FileNotFoundError("File not found: " + file_path)
 
-    paths = odoo.addons.__path__ + [config['root_path']]
-    for addons_path in paths:
-        addons_path = os.path.normpath(os.path.normcase(addons_path)) + os.sep
-        if name.startswith(addons_path):
-            break
-    else:
-        raise ValueError("Unknown path: %s" % name)
-
-    if basename is None:
-        basename = name
-    # Give higher priority to module directories, which is
-    # a more common case than zipped modules.
-    if os.path.isfile(name):
-        if 'b' in mode:
-            fo = open(name, mode)
-        else:
-            fo = io.open(name, mode, encoding='utf-8')
-        if pathinfo:
-            return fo, name
-        return fo
-
-    # Support for loading modules in zipped form.
-    # This will not work for zipped modules that are sitting
-    # outside of known addons paths.
-    head = os.path.normpath(path)
-    zipname = False
-    while os.sep in head:
-        head, tail = os.path.split(head)
-        if not tail:
-            break
-        if zipname:
-            zipname = os.path.join(tail, zipname)
-        else:
-            zipname = tail
-        zpath = os.path.join(basedir, head + '.zip')
-        if zipfile.is_zipfile(zpath):
-            zfile = zipfile.ZipFile(zpath)
-            try:
-                fo = io.BytesIO()
-                fo.write(zfile.read(os.path.join(
-                    os.path.basename(head), zipname).replace(
-                        os.sep, '/')))
-                fo.seek(0)
-                if pathinfo:
-                    return fo, name
-                return fo
-            except Exception:
-                pass
-    # Not found
-    if name.endswith('.rml'):
-        raise IOError('Report %r does not exist or has been deleted' % basename)
-    raise IOError('File not found: %s' % basename)
+def file_open(name, mode="r", filter_ext=None):
+    """Open a file from within the addons_path directories, as an absolute or relative path.
 
+    Examples::
+
+    >>> file_open('hr/static/description/icon.png')
+    >>> file_open('hr/static/description/icon.png', filter_ext=('.png', '.jpg'))
+    >>> with file_open('/opt/odoo/addons/hr/static/description/icon.png', 'rb') as f:
+    ...     contents = f.read()
+
+    :param name: absolute or relative path to a file located inside an addon
+    :param mode: file open mode, as for `open()`
+    :param list[str] filter_ext: optional list of supported extensions (lowercase, with leading dot)
+    :return: file object, as returned by `open()`
+    :raise FileNotFoundError: if the file is not found under the known `addons_path` directories
+    :raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
+    """
+    path = file_path(name, filter_ext=filter_ext)
+    if os.path.isfile(path):
+        if 'b' not in mode:
+            # Force encoding for text mode, as system locale could affect default encoding,
+            # even with the latest Python 3 versions.
+            # Note: This is not covered by a unit test, due to the platform dependency.
+            #       For testing purposes you should be able to force a non-UTF8 encoding with:
+            #         `sudo locale-gen fr_FR; LC_ALL=fr_FR.iso8859-1 python3 ...'
+            # See also PEP-540, although we can't rely on that at the moment.
+            return open(path, mode, encoding="utf-8")
+        return open(path, mode)
+    raise FileNotFoundError("Not a file: " + name)
 
 #----------------------------------------------------------
 # iterables