Merge commit from fork

User-controlled template sources were rendered with the unrestricted
jinja2.Template, allowing authenticated users to achieve remote code
execution. All rendering now goes through a single shared
RenderSandboxedTemplate() helper backed by jinja2.sandbox, which denies
unsafe attribute access (e.g. __globals__, __class__) while keeping the
intended templating features working:

- POST /api/email/preview: Body and Subject (GHSA-8q4m-52pw-hfmw)
- POST /api/email/send: Body and Subject (same sink class)
- POST /api/updateWireguardConfigurationInfo (OverridePeerSettings):
  values were rendered before validation, so the payload executed
  before __validateOverridePeerSettings could reject it
- Peer.downloadPeer(): rendered configuration file includes raw
  OverridePeerSettings values; also rendered for dashboard clients
  via GetAssignedPeers

Verified: lipsum.__globals__, __class__.__mro__, TemplateReference,
cycler/joiner/namespace escape chains all raise SecurityError; normal
templates ({{ configuration.* }}, {{ peer.* }}, filters, loops) render
unchanged.

Co-authored-by: DaanSelen <80752476+DaanSelen@users.noreply.github.com>
This commit is contained in:
Randark
2026-10-26 10:29:51 +01:00
committed by GitHub
co-authored by DaanSelen
parent 4ee6b462dc
commit dded6a86df
4 changed files with 28 additions and 15 deletions
+7 -9
View File
@@ -9,8 +9,7 @@ from zipfile import ZipFile
from datetime import datetime, timedelta from datetime import datetime, timedelta
import sqlalchemy import sqlalchemy
from jinja2 import Template from flask import Flask, request, render_template, session, send_file, current_app
from flask import Flask, request, render_template, session, send_file, current_app, redirect, url_for
from flask_cors import CORS from flask_cors import CORS
from icmplib import ping, traceroute from icmplib import ping, traceroute
from flask.json.provider import DefaultJSONProvider from flask.json.provider import DefaultJSONProvider
@@ -20,7 +19,8 @@ from sqlalchemy import RowMapping
from modules.Utilities import ( from modules.Utilities import (
RegexMatch, StringToBoolean, ValidateDNSAddress, RegexMatch, StringToBoolean, ValidateDNSAddress,
GenerateWireguardPublicKey, GenerateWireguardPrivateKey GenerateWireguardPublicKey, GenerateWireguardPrivateKey,
RenderSandboxedTemplate
) )
from packaging import version from packaging import version
from modules.Email import EmailSender from modules.Email import EmailSender
@@ -1597,10 +1597,9 @@ def API_Email_Send():
if configuration is not None: if configuration is not None:
fp, p = configuration.searchPeer(data.get('Peer')) fp, p = configuration.searchPeer(data.get('Peer'))
if fp: if fp:
template = Template(body)
download = p.downloadPeer() download = p.downloadPeer()
body = template.render(peer=p.toJson(), configurationFile=download) body = RenderSandboxedTemplate(body, peer=p.toJson(), configurationFile=download)
subject = Template(data.get('Subject', '')).render(peer=p.toJson(), configurationFile=download) subject = RenderSandboxedTemplate(data.get('Subject', ''), peer=p.toJson(), configurationFile=download)
if data.get('IncludeAttachment', False): if data.get('IncludeAttachment', False):
u = str(uuid4()) u = str(uuid4())
peerName = p.toJson().get('name', '').strip() peerName = p.toJson().get('name', '').strip()
@@ -1629,11 +1628,10 @@ def API_Email_PreviewBody():
return ResponseObject(False, "Peer does not exist") return ResponseObject(False, "Peer does not exist")
try: try:
template = Template(body)
download = p.downloadPeer() download = p.downloadPeer()
return ResponseObject(data={ return ResponseObject(data={
"Body": Template(body).render(peer=p.toJson(), configurationFile=download), "Body": RenderSandboxedTemplate(body, peer=p.toJson(), configurationFile=download),
"Subject": Template(subject).render(peer=p.toJson(), configurationFile=download) "Subject": RenderSandboxedTemplate(subject, peer=p.toJson(), configurationFile=download)
}) })
except Exception as e: except Exception as e:
return ResponseObject(False, message=str(e)) return ResponseObject(False, message=str(e))
+3 -3
View File
@@ -7,12 +7,12 @@ import json
import os, subprocess, uuid, random, re import os, subprocess, uuid, random, re
from datetime import timedelta from datetime import timedelta
import jinja2
import sqlalchemy as db import sqlalchemy as db
from .PeerJob import PeerJob from .PeerJob import PeerJob
from flask import current_app from flask import current_app
from .PeerShareLink import PeerShareLink from .PeerShareLink import PeerShareLink
from .Utilities import GenerateWireguardPublicKey, CheckAddress, ValidateDNSAddress from .Utilities import GenerateWireguardPublicKey, CheckAddress, ValidateDNSAddress, \
RenderSandboxedTemplate
class Peer: class Peer:
@@ -237,7 +237,7 @@ class Peer:
if val is not None and ((type(val) is str and len(val) > 0) or (type(val) is int and val > 0)): if val is not None and ((type(val) is str and len(val) > 0) or (type(val) is int and val > 0)):
final["file"] += f"{key} = {val}\n" final["file"] += f"{key} = {val}\n"
final["file"] = jinja2.Template(final["file"]).render(configuration=self.configuration) final["file"] = RenderSandboxedTemplate(final["file"], configuration=self.configuration)
if self.configuration.Protocol == "awg": if self.configuration.Protocol == "awg":
+15
View File
@@ -1,6 +1,21 @@
import re, ipaddress import re, ipaddress
import subprocess import subprocess
import sqlalchemy import sqlalchemy
from jinja2.sandbox import SandboxedEnvironment
sandboxEnvironment = SandboxedEnvironment()
def RenderSandboxedTemplate(source: str, **context) -> str:
"""
Render a Jinja2 template within a sandboxed environment. Template sources built with
user-provided content (e.g. email body, peer configuration file) must go through this
function, so unsafe attribute access (such as __globals__) is denied and template
injection cannot escalate into arbitrary code execution.
@param source: Template source string, may contain user-provided content
@param context: Variables passed into the template
@return: Rendered template
"""
return sandboxEnvironment.from_string(source).render(**context)
def RegexMatch(regex, text) -> bool: def RegexMatch(regex, text) -> bool:
""" """
+3 -3
View File
@@ -3,7 +3,6 @@ WireGuard Configuration
""" """
from typing import Any from typing import Any
import jinja2
import sqlalchemy, random, shutil, configparser, ipaddress, os, subprocess, time, re, uuid, psutil, traceback import sqlalchemy, random, shutil, configparser, ipaddress, os, subprocess, time, re, uuid, psutil, traceback
from zipfile import ZipFile from zipfile import ZipFile
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -21,7 +20,8 @@ from .Utilities import StringToBoolean, \
ValidateDNSAddress, \ ValidateDNSAddress, \
ValidateEndpointAllowedIPs, \ ValidateEndpointAllowedIPs, \
CheckAddress, \ CheckAddress, \
CheckPeerKey CheckPeerKey, \
RenderSandboxedTemplate
from .WireguardConfigurationInfo import WireguardConfigurationInfo, PeerGroupsClass from .WireguardConfigurationInfo import WireguardConfigurationInfo, PeerGroupsClass
from .DashboardWebHooks import DashboardWebHooks from .DashboardWebHooks import DashboardWebHooks
@@ -1242,7 +1242,7 @@ class WireguardConfiguration:
elif key == "OverridePeerSettings": elif key == "OverridePeerSettings":
for (key, val) in value.items(): for (key, val) in value.items():
try: try:
status, msg = self.__validateOverridePeerSettings(key, jinja2.Template(val).render(configuration=self.toJson())) status, msg = self.__validateOverridePeerSettings(key, RenderSandboxedTemplate(val, configuration=self.toJson()))
if not status: if not status:
return False, msg, key return False, msg, key
except Exception as e: except Exception as e: