Skip to content

Commit

Permalink
editable packages: only allow reading or editing files inside the pac…
Browse files Browse the repository at this point in the history
…kage's directory

This commit adds validation on views which read, write or delete files
belonging to extensions and themes.
  • Loading branch information
christianp committed Feb 27, 2024
1 parent 5e5ac4e commit ae67928
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 17 deletions.
16 changes: 13 additions & 3 deletions editor/forms.py
Expand Up @@ -278,8 +278,18 @@ def clean_zipfile(self):
raise forms.ValidationError('Uploaded file is not a zip file')
return value

class PackageFileFormMixin:
def clean(self):
cleaned_data = super().clean()
package = self.instance
filename = cleaned_data.get('filename')
package_path = Path(package.extracted_path).resolve()
path = (package_path / filename).resolve()
if not path.is_relative_to(package_path):
raise forms.ValidationError("This file is not in the package's directory.")
return cleaned_data

class EditPackageForm(forms.ModelForm):
class EditPackageForm(PackageFileFormMixin, forms.ModelForm):

"""Form to edit a file in a package."""

Expand All @@ -299,7 +309,7 @@ def save(self, commit=True):
f.write(self.cleaned_data.get('source'))
return package

class EditPackageReplaceFileForm(forms.ModelForm):
class EditPackageReplaceFileForm(PackageFileFormMixin, forms.ModelForm):

"""Form to replace a file in a package."""

Expand Down Expand Up @@ -330,7 +340,7 @@ class ReplaceExtensionFileForm(EditPackageReplaceFileForm):
class Meta(EditPackageReplaceFileForm.Meta):
model = Extension

class PackageDeleteFileForm(forms.ModelForm):
class PackageDeleteFileForm(PackageFileFormMixin, forms.ModelForm):
filename = forms.CharField(widget=forms.HiddenInput)

class Meta:
Expand Down
6 changes: 3 additions & 3 deletions editor/templates/editable_package/edit_base.html
Expand Up @@ -35,7 +35,7 @@ <h1 class="name-header">
{% endif %}
</ul>
{% if object.editable %}
<div class="panel-heading">Files {{filename}}</div>
<div class="panel-heading">Files</div>
<ul class="list-group">
{% if parent_directory != current_directory %}
<div class="list-group-item parent-directories">
Expand All @@ -49,8 +49,8 @@ <h1 class="name-header">
</nav>
</div>
{% endif %}
{% for fname, is_dir in filenames %}
<a class="list-group-item monospace{% if fname == filename %} active{% endif %}{% if is_dir %} dir{% endif %}" href="{% package_url 'edit_source' object.pk %}?filename={{fname}}">{{fname}}</a>
{% for absfname, relfname, is_dir in filenames %}
<a class="list-group-item monospace{% if absfname == filename %} active{% endif %}{% if is_dir %} dir{% endif %}" href="{% package_url 'edit_source' object.pk %}?filename={{absfname}}">{{relfname}}</a>
{% endfor %}

{% if editable %}
Expand Down
20 changes: 15 additions & 5 deletions editor/templates/editable_package/edit_source.html
Expand Up @@ -57,22 +57,32 @@
{% endif %}
</div>

<form action="{% package_url 'edit_source' form.instance.pk %}" method="POST" id="save-file">
{% if is_image %}
{% if exists %}
<a href="{{file_url}}" download="{{filename_without_directories}}"><img class="image-file thumbnail" src="{{file_url}}"></a>
{% endif %}
{% else %}
{% if not is_binary %}
<form action="{% package_url 'edit_source' form.instance.pk %}" method="POST" id="save-file">
{% csrf_token %}
{% for field in form %}
{{field}}
{% if form.errors %}
{% for error_group in form.errors.values %}
{% for error in error_group %}
<div class="alert alert-danger">
<p>{{error}}</p>
</div>
{% endfor %}
{% endfor %}
</form>
{% endif %}

{% csrf_token %}
{% for field in form %}
{{field}}
{% endfor %}
{% else %}
<p>This is a binary file.</p>
{% endif %}
{% endif %}
</form>
{% endif %}
{% endblock package_edit_content %}

Expand Down
39 changes: 33 additions & 6 deletions editor/views/editable_package.py
Expand Up @@ -9,6 +9,7 @@
from django import http
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.views import generic
from django.urls import reverse
from django.utils.timezone import make_aware
Expand All @@ -33,15 +34,25 @@ def get_context_data(self,**kwargs):

package = self.get_object()

context['filenames'] = [(x, (self.get_object().extracted_path / x).is_dir()) for x in self.get_package_filenames()]
package_path = Path(package.extracted_path).resolve()

current_dir = (package_path / self.get_current_directory()).resolve()

# For a given path, return a triple: (path relative to package root, path relative to current directory, is a directory?)
def path_info(path):
abspath = (package_path / path).resolve()
return (abspath.relative_to(package_path), abspath.relative_to(current_dir), abspath.is_dir())

context['filenames'] = [path_info(x) for x in self.get_package_filenames()]
context['editable'] = self.package.can_be_edited_by(self.request.user)
context['upload_file_form'] = self.upload_file_form_class(instance=self.get_object())

filename = self.get_filename()

if filename is not None:
path = context['path'] = self.get_path()
current_directory = context['current_directory'] = self.get_current_directory().relative_to(package.extracted_path)
context['filename'] = path.relative_to(package.extracted_path)
context['filename'] = (package_path / path).resolve().relative_to(package_path)
context['parent_directory'] = current_directory.parent

context['exists'] = path.exists()
Expand Down Expand Up @@ -72,10 +83,14 @@ def get_filename(self):

def get_path(self):
package = self.get_object()
return Path(package.extracted_path) / self.get_filename()
package_path = Path(package.extracted_path)
path = package_path / self.get_filename()
if not path.is_relative_to(package_path):
raise PermissionDenied("This file is not in the package's directory.")
return path

def get_current_directory(self):
path = self.get_path()
path = self.get_path().resolve()
if path.is_dir():
return path
else:
Expand All @@ -93,12 +108,15 @@ def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)

def dispatch(self,request,*args,**kwargs):
package = self.get_object()
package = self.package = self.get_object()
if not package.editable:
return forbidden_response(self.request,"This package is not editable.")
return super().dispatch(request,*args,**kwargs)

def load_source(self,path):
if not path.resolve().is_relative_to(self.package.extracted_path):
raise PermissionDenied(f"This file is not in the package's directory.")

if path.is_dir():
return None
try:
Expand Down Expand Up @@ -135,8 +153,14 @@ def get_context_data(self, **kwargs):
path = context['path'] = self.get_path()

filenames = context['filenames']

package_path = Path(package.extracted_path)

current_dir = (package_path / self.get_current_directory()).resolve()

if not context['exists']:
filenames.append((self.get_current_directory() / filename, False))
filenames.append((Path(filename), (package_path / filename).resolve().relative_to(current_dir), False))

filenames.sort()

context['is_binary'] = self.is_binary
Expand Down Expand Up @@ -194,6 +218,9 @@ def get_success_url(self):

class AccessView(ShowPackageFilesMixin, AuthorRequiredMixin, generic.UpdateView):
template_name = 'editable_package/access.html'

def get_filename(self):
return None

def get_form_kwargs(self, *args, **kwargs):
kwargs = super().get_form_kwargs(*args,**kwargs)
Expand Down
3 changes: 3 additions & 0 deletions editor/views/theme.py
Expand Up @@ -23,6 +23,9 @@ def get_filename(self):
return self.place_filename(filename)

def place_filename(self, filename):
if filename is None:
return None

extension_dirs = {
'.md': '',
'.txt': '',
Expand Down

0 comments on commit ae67928

Please sign in to comment.