Skip to content

Commit b256c67

Browse files
committed
CHANGES:
- Mission uploads can now handle "later" replacements, if people are flying. - /download <Missions> reworked to provide the latest mission, independently if it was .orig, .miz or .dcssb\.miz BUGFIX: - Possible path injection in /download fixed.
1 parent dd3cd64 commit b256c67

File tree

8 files changed

+190
-45
lines changed

8 files changed

+190
-45
lines changed

core/data/impl/serverimpl.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -427,13 +427,16 @@ def do_startup(self):
427427
missions = []
428428
for mission in self.settings['missionList']:
429429
if '.dcssb' in mission:
430-
secondary = os.path.join(os.path.dirname(os.path.dirname(mission)), os.path.basename(mission))
430+
_mission = os.path.join(os.path.dirname(os.path.dirname(mission)), os.path.basename(mission))
431431
else:
432-
secondary = os.path.join(os.path.dirname(mission), '.dcssb', os.path.basename(mission))
433-
if os.path.exists(mission):
432+
_mission = mission
433+
# check if the orig file has been updated
434+
orig = _mission + '.orig'
435+
if os.path.exists(orig) and os.path.getmtime(orig) > os.path.getmtime(mission):
436+
shutil.copy2(orig, _mission)
437+
missions.append(_mission)
438+
elif os.path.exists(mission):
434439
missions.append(mission)
435-
elif os.path.exists(secondary):
436-
missions.append(secondary)
437440
else:
438441
self.log.warning(f"Removing mission {mission} from serverSettings.lua as it could not be found!")
439442
if len(missions) != len(self.settings['missionList']):
@@ -724,20 +727,25 @@ async def keep_alive(self):
724727
WHERE node = %s AND server_name = %s
725728
""", (self.node.name, self.name))
726729

727-
async def uploadMission(self, filename: str, url: str, force: bool = False, missions_dir: str = None) -> UploadStatus:
730+
async def uploadMission(self, filename: str, url: str, *, missions_dir: str = None, force: bool = False,
731+
orig = False) -> UploadStatus:
728732
if not missions_dir:
729733
missions_dir = self.instance.missions_dir
730734
filename = os.path.normpath(os.path.join(missions_dir, filename))
731-
secondary = os.path.join(os.path.dirname(filename), '.dcssb', os.path.basename(filename))
732-
for idx, name in enumerate(self.settings['missionList']):
733-
if (os.path.normpath(name) == filename) or (os.path.normpath(name) == secondary):
734-
if self.current_mission and idx == int(self.settings['listStartIndex']) - 1:
735-
if not force:
736-
return UploadStatus.FILE_IN_USE
737-
add = True
738-
break
735+
if orig:
736+
filename += '.orig'
737+
add = False
739738
else:
740-
add = self.locals.get('autoadd', True)
739+
secondary = os.path.join(os.path.dirname(filename), '.dcssb', os.path.basename(filename))
740+
for idx, name in enumerate(self.settings['missionList']):
741+
if (os.path.normpath(name) == filename) or (os.path.normpath(name) == secondary):
742+
if self.current_mission and idx == int(self.settings['listStartIndex']) - 1:
743+
if not force:
744+
return UploadStatus.FILE_IN_USE
745+
add = True
746+
break
747+
else:
748+
add = self.locals.get('autoadd', True)
741749
rc = await self.node.write_file(filename, url, force)
742750
if rc != UploadStatus.OK:
743751
return rc
@@ -859,6 +867,24 @@ async def replaceMission(self, mission_id: int, path: str) -> list[str]:
859867
return self.settings['missionList']
860868

861869
async def loadMission(self, mission: Union[int, str], modify_mission: Optional[bool] = True) -> bool:
870+
# check if we re-load the running mission
871+
start_index = int(self.settings['listStartIndex'])
872+
if ((isinstance(mission, int) and mission == start_index) or
873+
(isinstance(mission, str) and mission == self._get_current_mission_file())):
874+
mission = self.settings['missionList'][start_index - 1]
875+
# now determine the original mission name
876+
_mission = utils.get_orig_file(mission)
877+
# check if the orig file has been replaced
878+
if os.path.exists(_mission) and os.path.getmtime(_mission) > os.path.getmtime(mission):
879+
new_filename = utils.create_writable_mission(mission)
880+
# we can't write the original one, so use the copy
881+
if new_filename != mission:
882+
shutil.copy2(_mission, new_filename)
883+
await self.replaceMission(start_index, new_filename)
884+
return await self.loadMission(start_index, modify_mission=modify_mission)
885+
else:
886+
return await self.loadMission(start_index, modify_mission=modify_mission)
887+
862888
if isinstance(mission, int):
863889
if mission > len(self.settings['missionList']):
864890
mission = 1
@@ -878,7 +904,7 @@ async def loadMission(self, mission: Union[int, str], modify_mission: Optional[b
878904
else:
879905
try:
880906
idx = self.settings['missionList'].index(filename) + 1
881-
if idx == int(self.settings['listStartIndex']):
907+
if idx == start_index:
882908
rc = await self.send_to_dcs_sync({"command": "startMission", "filename": filename})
883909
else:
884910
rc = await self.send_to_dcs_sync({"command": "startMission", "id": idx})
@@ -971,3 +997,30 @@ async def uninstall_extension(self, name: str) -> None:
971997
async def cleanup(self) -> None:
972998
tempdir = os.path.join(tempfile.gettempdir(), self.instance.name)
973999
await asyncio.to_thread(utils.safe_rmtree, tempdir)
1000+
1001+
async def getAllMissionFiles(self) -> list[tuple[str, str]]:
1002+
def shorten_filename(file: str) -> str:
1003+
if file.endswith('.orig'):
1004+
return file[:-5]
1005+
if '.dcssb' in file:
1006+
return file.replace(os.path.sep + '.dcssb', '')
1007+
return file
1008+
1009+
result = []
1010+
base_dir, all_missions = await self.node.list_directory(self.instance.missions_dir, pattern="*.miz",
1011+
ignore=['.dcssb', 'Scripts', 'Saves'], traverse=True)
1012+
for mission in all_missions:
1013+
orig = utils.get_orig_file(mission, create_file=False)
1014+
secondary = os.path.join(
1015+
os.path.dirname(mission), '.dcssb', os.path.basename(mission)
1016+
)
1017+
if orig and os.path.getmtime(orig) > os.path.getmtime(mission):
1018+
file = orig
1019+
else:
1020+
file = mission
1021+
if os.path.exists(secondary) and os.path.getmtime(secondary) > os.path.getmtime(file):
1022+
file = secondary
1023+
1024+
result.append((shorten_filename(file), file))
1025+
1026+
return result

core/data/proxy/serverproxy.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ async def prepare_extensions(self):
139139
"server_name": self.name
140140
}, node=self.node.name, timeout=timeout)
141141

142-
async def uploadMission(self, filename: str, url: str, force: bool = False, missions_dir: str = None) -> UploadStatus:
142+
async def uploadMission(self, filename: str, url: str, *, missions_dir: str = None, force: bool = False,
143+
orig = False) -> UploadStatus:
143144
timeout = 120 if not self.node.slow_system else 240
144145
data = await self.bus.send_to_node_sync({
145146
"command": "rpc",
@@ -148,8 +149,9 @@ async def uploadMission(self, filename: str, url: str, force: bool = False, miss
148149
"params": {
149150
"filename": filename,
150151
"url": url,
152+
"missions_dir": missions_dir,
151153
"force": force,
152-
"missions_dir": missions_dir
154+
"orig": orig
153155
},
154156
"server_name": self.name
155157
}, timeout=timeout, node=self.node.name)
@@ -416,3 +418,13 @@ async def cleanup(self) -> None:
416418
"method": "cleanup",
417419
"server_name": self.name
418420
}, timeout=timeout, node=self.node.name)
421+
422+
async def getAllMissionFiles(self) -> list[str]:
423+
timeout = 180 if not self.node.slow_system else 300
424+
data = await self.bus.send_to_node_sync({
425+
"command": "rpc",
426+
"object": "Server",
427+
"method": "getAllMissionFiles",
428+
"server_name": self.name
429+
}, timeout=timeout, node=self.node.name)
430+
return data['return']

core/data/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,14 @@ async def loadNextMission(self, modify_mission: Optional[bool] = True) -> bool:
358358
async def getMissionList(self) -> list[str]:
359359
raise NotImplemented()
360360

361+
async def getAllMissionFiles(self) -> list[str]:
362+
raise NotImplemented()
363+
361364
async def modifyMission(self, filename: str, preset: Union[list, dict]) -> str:
362365
raise NotImplemented()
363366

364-
async def uploadMission(self, filename: str, url: str, force: bool = False, missions_dir: str = None) -> UploadStatus:
367+
async def uploadMission(self, filename: str, url: str, *, missions_dir: str = None, force: bool = False,
368+
orig = False) -> UploadStatus:
365369
raise NotImplemented()
366370

367371
async def apply_mission_changes(self, filename: Optional[str] = None) -> str:

core/utils/dcs.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,20 @@ def create_writable_mission(filename: str) -> str:
161161
return new_filename
162162

163163

164-
def get_orig_file(filename: str) -> str:
164+
def get_orig_file(filename: str, *, create_file: bool = True) -> Optional[str]:
165165
if '.dcssb' in filename:
166-
orig_file = os.path.join(os.path.dirname(filename).replace('.dcssb', ''),
167-
os.path.basename(filename)) + '.orig'
166+
mission_file = os.path.join(os.path.dirname(filename).replace('.dcssb', ''),
167+
os.path.basename(filename))
168168
else:
169-
orig_file = filename + '.orig'
170-
# make an initial backup, if there is none
171-
if not os.path.exists(orig_file):
172-
shutil.copy2(filename, orig_file)
169+
mission_file = filename
170+
orig_file = mission_file + '.orig'
171+
if not os.path.exists(orig_file):
172+
if create_file:
173+
# make an initial backup, if there is none
174+
if not os.path.exists(orig_file):
175+
shutil.copy2(filename, orig_file)
176+
else:
177+
return None
173178
return orig_file
174179

175180

core/utils/discord.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -345,14 +345,14 @@ async def on_cancel(self, interaction: Interaction, _: Button):
345345
self.stop()
346346

347347

348-
async def populated_question(interaction: discord.Interaction, question: str, message: Optional[str] = None,
348+
async def populated_question(ctx: Union[commands.Context, discord.Interaction], question: str, message: Optional[str] = None,
349349
ephemeral: Optional[bool] = True) -> Optional[str]:
350350
"""
351351
Same as yn_question, but adds an option "Later". The usual use-case of this function would be
352352
if people are flying atm, and you want to ask to trigger an action that would affect their experience (aka stop
353353
the server).
354354
355-
:param interaction: The discord interaction object.
355+
:param ctx: The discord context or interaction object.
356356
:param question: The question to be displayed in the embed.
357357
:param message: An optional message to be displayed in the embed.
358358
:param ephemeral: Whether the interaction response should be ephemeral. Default is True.
@@ -361,14 +361,10 @@ async def populated_question(interaction: discord.Interaction, question: str, me
361361
embed = discord.Embed(title='People are flying!', description=question, color=discord.Color.red())
362362
if message is not None:
363363
embed.add_field(name=message, value='_ _')
364+
if isinstance(ctx, discord.Interaction):
365+
ctx = await ctx.client.get_context(ctx)
364366
view = PopulatedQuestionView()
365-
# noinspection PyUnresolvedReferences
366-
if interaction.response.is_done():
367-
msg = await interaction.followup.send(embed=embed, view=view, ephemeral=ephemeral)
368-
else:
369-
# noinspection PyUnresolvedReferences
370-
await interaction.response.send_message(embed=embed, view=view, ephemeral=ephemeral)
371-
msg = await interaction.original_response()
367+
msg = await ctx.send(embed=embed, view=view, ephemeral=ephemeral)
372368
try:
373369
if await view.wait():
374370
return None

core/utils/os.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"set_password",
4646
"get_password",
4747
"delete_password",
48-
"CloudRotatingFileHandler"
48+
"CloudRotatingFileHandler",
49+
"sanitize_filename"
4950
]
5051

5152
logger = logging.getLogger(__name__)
@@ -234,6 +235,38 @@ def delete_password(key: str, config_dir='config'):
234235
raise ValueError(key)
235236

236237

238+
def sanitize_filename(filename: str, base_directory: str) -> str:
239+
"""
240+
Sanitizes an input filename to prevent relative path injection.
241+
Ensures the file path is within the `base_directory`.
242+
243+
Args:
244+
filename (str): The input filename to sanitize.
245+
base_directory (str): The base directory where all downloads should be stored.
246+
247+
Returns:
248+
str: A sanitized, safe file path.
249+
250+
Raises:
251+
ValueError: If the filename contains invalid patterns or escapes the base directory.
252+
"""
253+
# Ensure the base_directory is absolute
254+
base_directory = os.path.abspath(base_directory)
255+
256+
# Resolve the filename into an absolute path
257+
resolved_path = os.path.abspath(os.path.join(base_directory, filename))
258+
259+
# Ensure the resolved path is within the base directory
260+
if not os.path.commonpath([base_directory, resolved_path]) == base_directory:
261+
raise ValueError(f"Relative path injection attempt detected: {filename}")
262+
263+
# Optional: Check file name for illegal characters (e.g., reject ../)
264+
if ".." in filename or filename.startswith(("/")):
265+
raise ValueError(f"Invalid filename detected: {filename}")
266+
267+
return resolved_path
268+
269+
237270
class CloudRotatingFileHandler(RotatingFileHandler):
238271
def shouldRollover(self, record):
239272
"""

plugins/admin/commands.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ async def label_autocomplete(interaction: discord.Interaction, current: str) ->
8989
except Exception as ex:
9090
interaction.client.log.exception(ex)
9191

92+
async def _mission_file_autocomplete(interaction: discord.Interaction, current: str) -> list[app_commands.Choice[str]]:
93+
try:
94+
server: Server = await utils.ServerTransformer().transform(
95+
interaction, utils.get_interaction_param(interaction, 'server'))
96+
file_list = await server.getAllMissionFiles()
97+
exp_base = await server.get_missions_dir()
98+
choices: list[app_commands.Choice[str]] = [
99+
app_commands.Choice(name=os.path.relpath(x[0], exp_base), value=os.path.relpath(x[1], exp_base))
100+
for x in file_list
101+
if not current or current.casefold() in os.path.relpath(x[0], exp_base).casefold()
102+
]
103+
return choices[:25]
104+
except Exception as ex:
105+
interaction.client.log.exception(ex)
106+
92107

93108
async def file_autocomplete(interaction: discord.Interaction, current: str) -> list[app_commands.Choice[str]]:
94109
if not await interaction.command._check_can_run(interaction):
@@ -99,15 +114,16 @@ async def file_autocomplete(interaction: discord.Interaction, current: str) -> l
99114
if not server:
100115
return []
101116
label = utils.get_interaction_param(interaction, "what")
117+
# missions will be handled differently
118+
if label == 'Missions':
119+
return await _mission_file_autocomplete(interaction, current)
102120
config = interaction.client.cogs['Admin'].get_config(server)
103121
try:
104122
config = next(x for x in config['downloads'] if x['label'] == label)
105123
except StopIteration:
106124
return []
107125
base_dir = config['directory'].format(server=server)
108-
exp_base, file_list = await server.node.list_directory(
109-
base_dir, pattern=config['pattern'], traverse=True, ignore=['.dcssb']
110-
)
126+
exp_base, file_list = await server.node.list_directory(base_dir, pattern=config['pattern'], traverse=True)
111127
choices: list[app_commands.Choice[str]] = [
112128
app_commands.Choice(name=os.path.relpath(x, exp_base), value=os.path.relpath(x, exp_base))
113129
for x in file_list
@@ -427,12 +443,27 @@ async def download(self, interaction: discord.Interaction,
427443
# noinspection PyUnresolvedReferences
428444
await interaction.response.defer(thinking=True, ephemeral=ephemeral)
429445
config = next(x for x in self.get_config(server)['downloads'] if x['label'] == what)
430-
path = os.path.join(config['directory'].format(server=server), filename)
446+
# double-check if that user can really download these files
447+
if config.get('discord') and not utils.check_roles(config['discord'], interaction.user):
448+
raise app_commands.CheckFailure()
449+
# make sure nobody injected a wrong path
450+
base_dir = config['directory'].format(server=server)
451+
try:
452+
path = utils.sanitize_filename(os.path.join(base_dir, filename), base_dir)
453+
except ValueError:
454+
await self.bot.audit("User attempted a relative file injection!",
455+
user=interaction.user, base_dir=base_dir, file=filename)
456+
await interaction.followup.send(_("You have been reported for trying to inject a relative path!"))
457+
return
458+
# now continue to download
459+
if filename.endswith('.orig'):
460+
filename = filename[:-5]
431461
try:
432462
file = await server.node.read_file(path)
433463
except FileNotFoundError:
434-
await interaction.followup.send(_("File {file} not found in directory {dir}.").format(
435-
file=filename, dir=config['directory'].format(server=server)))
464+
self.log.error(f"File {path} not found.")
465+
await interaction.followup.send(content=_("File {file} not found.").format(file=filename),
466+
ephemeral=True)
436467
return
437468
target = config.get('target')
438469
if target:

plugins/mission/upload.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,22 @@ async def handle_attachment(self, directory: str, att: discord.Attachment) -> Up
2020
ctx = await self.bot.get_context(self.message)
2121
rc = await self.server.uploadMission(att.filename, att.url, force=False, missions_dir=directory)
2222
if rc in [UploadStatus.FILE_IN_USE, UploadStatus.WRITE_ERROR]:
23-
if not await utils.yn_question(ctx, _('This mission is currently active.\n'
24-
'Do you want me to stop the DCS-server to replace it?')):
23+
if self.server.is_populated():
24+
what = await utils.populated_question(ctx, _('This mission is currently active.\n'
25+
'Do you want me to stop the DCS-server to replace it?'))
26+
else:
27+
what = 'yes'
28+
if what == 'yes':
29+
await self.server.stop()
30+
elif what == 'later':
31+
await self.server.uploadMission(att.filename, att.url, orig=True, force=True, missions_dir=directory)
32+
await self.channel.send(_('Mission "{mission}" uploaded to server {server}').format(
33+
mission=os.path.basename(att.filename)[:-4], server=self.server.display_name))
34+
# we return the old rc (file in use), to not force a mission load
35+
return rc
36+
else:
2537
await self.channel.send(_('Upload aborted.'))
2638
return rc
27-
await self.server.stop()
2839
elif rc == UploadStatus.FILE_EXISTS:
2940
self.log.debug("File exists, asking for overwrite.")
3041
if not await utils.yn_question(ctx, _('File exists. Do you want to overwrite it?')):

0 commit comments

Comments
 (0)