Skip to content

Commit

Permalink
Add 'softcode' templating.
Browse files Browse the repository at this point in the history
  • Loading branch information
Kelketek committed Feb 7, 2020
1 parent c91d9f0 commit c190ad2
Show file tree
Hide file tree
Showing 5 changed files with 482 additions and 0 deletions.
Empty file.
113 changes: 113 additions & 0 deletions evennia/contrib/collab/template/core.py
@@ -0,0 +1,113 @@
import traceback
from logging import getLogger

import sys
from django.conf import settings
from evennia.contrib.collab.template.extensions import perm_bind, getter, setter, perm_check
from evennia.utils.ansi import raw
from jinja2 import BaseLoader

from jinja2 import DebugUndefined
from jinja2 import TemplateSyntaxError
from jinja2.exceptions import TemplatesNotFound
from jinja2.sandbox import SandboxedEnvironment


logger = getLogger(__name__)


class ExpressionLoader(BaseLoader):
"""
Take a template's 'name' and evaluate it literally as the template.
"""
def get_source(self, environment, template):
return template, 'expression', None


class EvTemplateEnvironment(SandboxedEnvironment):
def is_safe_attribute(self, obj, attr, value):
if hasattr(obj, 'template_permitted') and attr in obj.template_permitted:
return True
return False

def is_safe_callable(self, obj):
return getattr(obj, 'evtemplate_safe', False)


ENV = None


def gen_env():
global ENV
ENV = EvTemplateEnvironment(
autoescape=False, undefined=DebugUndefined, extensions=settings.COLLAB_TEMPLATE_EXTENSIONS,
loader=ExpressionLoader(),
)


def evtemplate(string, run_as=None, me=None, this=None, how=None, here=None, context=None, **kwargs):
"""
Parses a Jinja2 template string in the sandboxed environment.
run_as: The object the program should run as.
me: The the one who is viewing the message.
this: The relevant item that the message is being executed for
(such as an object whose desc triggered this message)
here: The location in which this message is being executed.
how: A string that describes the cause for the rendering,
like 'desc' or 'succ'. Optional but highly recommended.
Jinja is not very consistent about its exception handling when it
concerns with helping a developer figure out where an error is.
There is probably a better way to handle the exceptions here, all the same.
"""
if not ENV:
gen_env()
if not me:
raise ValueError("You cannot render a template without an observer.")
if not run_as:
# If there is no one that the script should run as, it might mean that there's
# no owner for the object. That can't be trusted, so just return the raw
# template text, and log the issue.
logger.info("Refused to render template without run_as set. Returning raw string.")
return string
if not here:
here = me.location
if not this:
raise ValueError("Templates must have a 'this' to render for.")
kwargs.update({
'me': me,
'run_as': run_as,
'this': this,
'here': here,
'how': how,
'perm_check': perm_check,
'fetch': perm_bind(getter, run_as),
'store': perm_bind(setter, run_as),
})
context = context or {}
try:
return ENV.from_string(string, globals=kwargs).render(**context)
except TemplateSyntaxError as e:
# Something was wrong with the source template string.
if e.source:
source_lines = [raw(u"{}:{}".format(i, line)) for i, line in enumerate(e.source.split('\n'), start=1)]
source_lines[e.lineno - 1] = u"|r|h" + source_lines[e.lineno - 1] + u"|n"
source_lines.append(str(e))
else:
source_lines = [u"Error on unknown line (possibly caused by an included template): {}".format(e)]

return u'\n'.join(source_lines)
except TemplatesNotFound:
return u"Include error: Attempted to include a null value. Check to make sure your " \
u"include statement contains a template string."
except Exception as e:
# Now we have the sort of exceptions Jinja is (especially) bad at handling.
ex_type, ex, tb = sys.exc_info()
tb = traceback.extract_tb(tb)
if tb[-1][0] == '<template>':
extra = u" on line {}".format(tb[-1][1])
else:
extra = u''
return u"Error when executing template{}. {}: {}".format(extra, e.__class__.__name__, e)
140 changes: 140 additions & 0 deletions evennia/contrib/collab/template/extensions.py
@@ -0,0 +1,140 @@
import json
from collections import MutableMapping
from json import JSONEncoder

import time
from evennia.contrib.collab.perms import prefix_check, attr_check, collab_check
from evennia.contrib.gendersub import GENDER_PRONOUN_MAP, gender_sub
from evennia.typeclasses.models import TypedObject
from evennia.utils import inherits_from

from jinja2 import nodes
from jinja2.exceptions import SecurityError, TemplateSyntaxError
from jinja2.ext import Extension


def safe(func):
"""
Decorator for functions so that they can be called by the sandboxed template system.
"""
func.evtemplate_safe = True
return func


class AttribJSONEncoder(JSONEncoder):
"""
Decoder which can serialize references to TypeClassed objects.
It's not actually used for storing any information, but is written in a practical
fashion anyway.
"""
def default(self, o):
if isinstance(o, TypedObject):
return {'##DBREF': [o.id, time.mktime(o.date_created.timetuple())]}
return o


def get_gender_map(target):
gender = target.usrattributes.get("gender", default="neutral").lower()
gender_map = dict(GENDER_PRONOUN_MAP)
custom_map = target.usrattributes.get("custom_gender_map", default=None)
if inherits_from(custom_map, MutableMapping):
gender_map.update(custom_map)
gender = gender if gender in gender_map else 'neutral'
return gender, gender_map


class PronounsExtension(Extension):
"""
Adds pronoun substitutions.
"""
tags = {'pro'}

def parse(self, parser):
# We get the line number so that we can give
# that line number to the node we create by hand.
lineno = next(parser.stream).lineno

# now we parse a single expression that is used as the pronoun target.
try:
target = parser.parse_expression()
except TemplateSyntaxError:
target = nodes.Const(None)

# now we parse the body of the cache block up to `endpro` and
# drop the needle (which would always be `endpro` in that case)
body = parser.parse_statements(['name:endpro'], drop_needle=True)
ctx_ref = nodes.ContextReference()
# now return a `CallBlock` node that calls our _pronoun_sub
# helper method on this extension.
block = nodes.CallBlock(self.call_method('pronoun_sub', args=[target, ctx_ref]), [], [], body)
block.set_lineno(lineno)
return block

@safe
def pronoun_sub(self, target, context, caller):
if target is None:
target = context['me']
return gender_sub(target, caller())


def perm_bind(func, run_as):
"""
To be used with the below get/set functions for getting/setting
db attributes in a template.
"""
@safe
def wrapped(*args, **kwargs):
return func(run_as, *args, **kwargs)
return wrapped


def getter(run_as, target, prop_name):
"""
Fetches the value of a property.
"""
name, handler = prefix_check(target, prop_name)
if attr_check(run_as, target, 'read', handler):
result = handler.get(name)
# Let's verify this object is primitive only all the way down,
# and then make a different copy of it so that it is not saved
# implicitly. Implicit saves could result in a security error
# where a user who can read a mutable object could then change
# it and affect something they should not have access to.
try:
result = json.dumps(result)
except TypeError:
raise SecurityError(
"'{prop_name}' on {target} contains data which cannot be securely sandboxed.".format(
prop_name=prop_name, target=target)
)
return json.loads(result)
raise SecurityError(
"{run_as} does not have read access to property '{prop_name}' on {target}".format(
run_as=run_as, prop_name=prop_name, target=target,
)
)


def setter(run_as, target, prop_name, prop_val):
"""
Sets the value of a property.
"""
name, handler = prefix_check(target, prop_name)
if attr_check(run_as, target, 'write', handler):
try:
json.dumps(prop_val)
except TypeError:
raise SecurityError("{prop_val} contains data which cannot be securely sandboxed.".format(
prop_val=repr(prop_val)
))
handler.add(name, prop_val)
return ''
raise SecurityError(
"{run_as} does not have write access to property '{prop_name}' on {target}".format(
run_as=run_as, prop_name=prop_name, target=target,
)
)


perm_check = safe(collab_check)

0 comments on commit c190ad2

Please sign in to comment.