Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2026 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 :
23 : #include "filesystemapirestriction.h"
24 :
25 : #include <sstream>
26 :
27 : #include "global/io/fileinfo.h"
28 :
29 : #include "log.h"
30 :
31 : using namespace muse;
32 : using namespace muse::api;
33 :
34 : namespace {
35 : constexpr int MAX_SYMLINK_DEPTH = 40;
36 :
37 0 : std::string parentPath(const std::string& path)
38 : {
39 0 : if (path.empty() || path == "/") {
40 0 : return "/";
41 : }
42 :
43 : #ifdef Q_OS_WIN
44 : if (path.size() <= 3 && path.size() >= 2 && path[1] == ':') {
45 : return path.size() == 2 ? path + "/" : path;
46 : }
47 : if (path.size() >= 2 && path[0] == '/' && path[1] == '/') {
48 : const size_t serverEnd = path.find('/', 2);
49 : if (serverEnd == std::string::npos) {
50 : return path;
51 : }
52 : const size_t shareEnd = path.find('/', serverEnd + 1);
53 : if (shareEnd == std::string::npos) {
54 : return path;
55 : }
56 : }
57 : #endif
58 :
59 0 : const size_t slash = path.find_last_of('/');
60 0 : if (slash == std::string::npos) {
61 0 : return path;
62 : }
63 0 : if (slash == 0) {
64 0 : return "/";
65 : }
66 : #ifdef Q_OS_WIN
67 : if (slash == 2 && path[1] == ':') {
68 : return path.substr(0, 3);
69 : }
70 : #endif
71 0 : return path.substr(0, slash);
72 : }
73 :
74 0 : std::string joinPath(const std::string& base, const std::string& component)
75 : {
76 0 : if (base.empty() || base == "/") {
77 0 : return "/" + component;
78 : }
79 0 : if (base.back() == '/') {
80 0 : return base + component;
81 : }
82 0 : return base + "/" + component;
83 : }
84 :
85 0 : void stripTrailingSlash(std::string& path)
86 : {
87 0 : while (path.size() > 1 && path.back() == '/') {
88 : #ifdef Q_OS_WIN
89 : if (path.size() == 3 && path[1] == ':') {
90 : break;
91 : }
92 : #endif
93 0 : path.pop_back();
94 : }
95 0 : }
96 :
97 0 : bool isUnder(const std::string& target, std::string base)
98 : {
99 0 : stripTrailingSlash(base);
100 0 : if (target == base) {
101 0 : return true;
102 : }
103 0 : return target.size() > base.size()
104 0 : && target.compare(0, base.size(), base) == 0
105 0 : && target[base.size()] == '/';
106 : }
107 :
108 0 : std::string resolveWithDepth(const io::path_t& path, int depth)
109 : {
110 0 : if (depth > MAX_SYMLINK_DEPTH || path.empty()) {
111 0 : return {};
112 : }
113 :
114 0 : const std::string absolute = io::FileInfo(path).absoluteFilePath().toStdString();
115 0 : if (absolute.empty()) {
116 0 : return {};
117 : }
118 :
119 0 : std::string normalized;
120 0 : normalized.reserve(absolute.size());
121 0 : for (const char c : absolute) {
122 0 : normalized.push_back(c == '\\' ? '/' : c);
123 : }
124 :
125 0 : std::string current;
126 0 : size_t pos = 0;
127 : #ifdef Q_OS_WIN
128 : if (normalized.size() >= 2 && normalized[1] == ':') {
129 : current = normalized.substr(0, 2) + "/";
130 : pos = (normalized.size() > 2 && normalized[2] == '/') ? 3 : 2;
131 : } else if (normalized.size() >= 2 && normalized[0] == '/' && normalized[1] == '/') {
132 : const size_t serverEnd = normalized.find('/', 2);
133 : if (serverEnd == std::string::npos) {
134 : return {};
135 : }
136 : const size_t shareEnd = normalized.find('/', serverEnd + 1);
137 : if (shareEnd == std::string::npos) {
138 : current = normalized;
139 : pos = normalized.size();
140 : } else {
141 : current = normalized.substr(0, shareEnd);
142 : pos = shareEnd + 1;
143 : }
144 : } else
145 : #endif
146 0 : if (!normalized.empty() && normalized[0] == '/') {
147 0 : current = "/";
148 0 : pos = 1;
149 : } else {
150 0 : return {};
151 : }
152 :
153 0 : while (pos < normalized.size()) {
154 0 : const size_t nextSlash = normalized.find('/', pos);
155 : const std::string component = normalized.substr(
156 0 : pos, nextSlash == std::string::npos ? std::string::npos : nextSlash - pos);
157 0 : pos = nextSlash == std::string::npos ? normalized.size() : nextSlash + 1;
158 :
159 0 : if (component.empty() || component == ".") {
160 0 : continue;
161 : }
162 0 : if (component == "..") {
163 0 : current = parentPath(current);
164 0 : continue;
165 : }
166 :
167 0 : const std::string next = joinPath(current, component);
168 0 : const io::FileInfo info(next);
169 0 : std::string canonical = info.canonicalFilePath().toStdString();
170 0 : if (!canonical.empty()) {
171 0 : for (char& c : canonical) {
172 0 : if (c == '\\') {
173 0 : c = '/';
174 : }
175 : }
176 0 : current = canonical;
177 0 : continue;
178 0 : }
179 :
180 0 : if (info.isSymLink()) {
181 0 : const io::path_t target = info.symLinkTarget();
182 0 : if (target.empty()) {
183 0 : return {};
184 : }
185 0 : const std::string resolvedTarget = resolveWithDepth(target, depth + 1);
186 0 : if (resolvedTarget.empty()) {
187 0 : return {};
188 : }
189 0 : current = resolvedTarget;
190 0 : continue;
191 0 : }
192 :
193 0 : current = next;
194 0 : }
195 :
196 0 : stripTrailingSlash(current);
197 0 : return current;
198 0 : }
199 : }
200 :
201 0 : std::string FileSystemApiRestriction::resolvedPath(const io::path_t& path)
202 : {
203 0 : return resolveWithDepth(path, 0);
204 : }
205 :
206 0 : void FileSystemApiRestriction::addAllowedPathBase(const std::string& key, const io::path_t& path)
207 : {
208 0 : if (path.empty()) {
209 0 : m_allowedPathBases.erase(key);
210 0 : return;
211 : }
212 :
213 0 : const std::string resolved = resolvedPath(path);
214 0 : if (resolved.empty()) {
215 0 : m_allowedPathBases.erase(key);
216 0 : return;
217 : }
218 :
219 0 : m_allowedPathBases[key] = resolved;
220 0 : }
221 :
222 0 : void FileSystemApiRestriction::removeAllowedPathBase(const std::string& key)
223 : {
224 0 : m_allowedPathBases.erase(key);
225 0 : }
226 :
227 0 : void FileSystemApiRestriction::clearAllowedPathBases()
228 : {
229 0 : m_allowedPathBases.clear();
230 0 : }
231 :
232 0 : muse::Ret FileSystemApiRestriction::isPathAllowed(const io::path_t& path) const
233 : {
234 0 : if (m_allowedPathBases.empty()) {
235 0 : return muse::make_ret(Ret::Code::NotAllowed, "Path is not allowed");
236 : }
237 :
238 0 : const std::string resolved = resolvedPath(path);
239 0 : if (resolved.empty()) {
240 0 : LOGW() << "File blocked: path '" << path << "' could not be resolved";
241 0 : return muse::make_ret(Ret::Code::NotAllowed, "Path is not allowed");
242 : }
243 :
244 0 : bool allowed = false;
245 0 : for (const auto& p : m_allowedPathBases) {
246 0 : if (isUnder(resolved, p.second)) {
247 0 : allowed = true;
248 0 : break;
249 : }
250 : }
251 :
252 0 : if (!allowed) {
253 0 : LOGW() << "File blocked: path '" << resolved << "' is outside allowed directories";
254 0 : std::stringstream ss;
255 0 : ss << "Allowed directories: ";
256 0 : for (const auto& p : m_allowedPathBases) {
257 0 : ss << p.first << ": " << p.second << "\n";
258 : }
259 0 : LOGW() << ss.str();
260 0 : }
261 :
262 0 : return allowed ? muse::make_ok() : muse::make_ret(Ret::Code::NotAllowed, "Path is not allowed");
263 0 : }
|