Skip to content

TinyBugs1 - Tiny Path Traversal in the Obsidian Full Calendar Plugin

Posted on:May 17, 2023

This post is the first TinyBug article of the TinyBugs series.

The goal is to find a tiny little bug 🐛 into everyday software and investigate 🕵️.

Key Takeaways

This tiny bug allows creating a markdown file at an arbitrary location through calendar event creation in an Obsidian Community plugin.

The impact is non-existent as someone needs to create calendar events for the vulnerability to trigger, and only markdown files can be created…

However, the small lesson here is that Electron-based apps have access to the underlying file system.

Table of contents

Open Table of contents

Discovering Obsidian Plugins

Bonjour!

Quite a sunny day today 🌞.

I have been playing with my favorite note-taking app Obsidian, and just started using community plugins. I never had a look at Obsidian plugins before. So let’s start with the plugin I decided to use to have a cool calendar, the Full Calendar plugin.


The first thing to know is how to debug Obsidian plugins. The following blog post by mnaoumov explains how, from the Chrome Developer Tools (as Obsidian is Electron-based), it is possible to directly get to the code that is being run by the plugin, and debug it live.

From the post’s author, the only thing to know about a plugin is his ID. It can be found in the obsidian link to share the plugin, https://github.com/davish/obsidian-full-calendar, as seen in the plugin’s GitHub repository.

Opening the Developer Tools, typing the following JS code and double-clicking on the result will teleport us to the plugin’s JS code :

app.plugins.plugins["obsidian-full-calendar"].constructor

DevTools View Image

I only use the Full Note calendar type day-to-day, meaning that for each event that I add to the calendar, a markdown file is created like so:

Creating Event Image

Created Event Image

Creating Events

Looking at the code, when creating an event, the following piece of code gets called:

createEvent(event) {
    return __async(this, null, function*() {
        const path = `${this.directory}/${filenameForEvent(event)}`;
        if (this.app.getAbstractFileByPath(path)) {
            throw new Error(`Event at ${path} already exists.`);
        }
        const file = yield this.app.create(path, newFrontmatter(event));
        return {
            file,
            lineNumber: void 0
        };
    });
}

fileNameForEvent is a function that appends a .md name to the filename:

var filenameForEvent = (event)=>`${basenameFromEvent(event)}.md`;

baseNameFromEvent creates the event filename according to its type:

var basenameFromEvent = (event)=>{
        switch (event.type) {
        case void 0:
        case "single":
            return `${event.date} ${event.title}`;
        case "recurring":
            return `(Every ${event.daysOfWeek.join(",")}) ${event.title}`;
        case "rrule":
            return `(${rrulestr(event.rrule).toText()}) ${event.title}`;
        }
    }

The important line here is:

const path = `${this.directory}/${filenameForEvent(event)}`;

this.directory will point to the directory I picked for a specific calendar. So if I make a myevent event, a new file will be created at : calendar_directory/current_date myevent.md

Now, what if we create an event with a /../../ pattern ?

What If Image

For example, let’s say /../../badevent ?

The computed path value becomes 000-Rangement/001-Calendriers/006-Futur/2023-05-22 /../../badevent.md :

Created Event Image

And the file is created one folder above :

Created One Above Image

How far can you go?

We can write a markdown file in the home directory with and event called /../../../../../../badeventhome :

Reaching Home Image

Let’s write next to the Obsidian executable (/Applications/Obsidian.app/Contents/MacOS) with : /../../../../../../../../Applications/Obsidian.app/Contents/MacOS/badeventobsi

Reaching Obsidian Executable Image

We can write markdown files pretty much everywhere, provided we have the right to write to the folder.

There is no vulnerability when deleting events, as the path of the file used is not the one from the input box 🙉.

A Fix Attempt

To make fixes to an Obsidian plugin, you first have to get it from GitHub and run the following npm commands:

git clone https://github.com/davish/obsidian-full-calendar.git
cd obsidian-full-calendar
npm install
npm run dev

Please note that the latest commit at the time was 9d8f866b78f2a353ae5ff6db4bb28bb036cfd436 (14 April 2023) and to run the npm install command, I had to remove "fast-check": "^3.8.0" from the package.json file.

After modifying a file within the plugin repository, you have to run npm run dev. The command will generate a main.js file. This file must be copied to the location of the obsidian-full-calendar plugin folder of your Obsidian vault:

cp main.js <VAULT_LOCATION>/.obsidian/plugins/obsidian-full-calendar/

Now that the setup is ready, let’s fix the bug!

To fix the bug, the idea is:

  1. to use path’s join function
  2. check whether the resulting path starts with the calendar’s directory name.

The plugin code changes from:

const path = `${this.directory}/${filenameForEvent(event)}`;

To:

const event_filename = filenameForEvent(event);
const path = join(this.directory, event_filename);

if (!path.startsWith(this.directory)) {
    throw new Error(`Event at ${path} will not be in calendar directory.`);
}

Now, we can’t create arbitrary markdown files outside the calendar directory.

The tiny patch is available in this PR.

I thought that the normalizePath Obsidian function could also help, but according to this forum thread it only works for “Obsidian vault paths”. Testing shows that it only removes the leading / of a path, which is mentioned in the forum thread.

Closing Thoughts

Team Obsidian all the way!

À bientôt ~