2
# Copyright (C) 2007-2008 The University of Melbourne
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
22
# Runs a student script in a safe execution environment.
25
from ivle import studpath
26
from ivle.util import IVLEJailError, split_path
36
# TODO: Make progressive output work
37
# Question: Will having a large buffer size stop progressive output from
38
# working on smaller output
40
CGI_BLOCK_SIZE = 65535
41
PATH = "/usr/local/bin:/usr/bin:/bin"
43
def interpret_file(req, owner, jail_dir, filename, interpreter, gentle=True,
45
"""Serves a file by interpreting it using one of IVLE's builtin
46
interpreters. All interpreters are intended to run in the user's jail. The
47
jail location is provided as an argument to the interpreter but it is up
48
to the individual interpreters to create the jail.
50
req: An IVLE request object.
51
owner: The user who owns the file being served.
52
jail_dir: Absolute path to the user's jail.
53
filename: Absolute filename within the user's jail.
54
interpreter: A function object to call.
56
overrides: A dict mapping env var names to strings, to override arbitrary
57
environment variables in the resulting CGI environent.
59
# We can't test here whether or not the target file actually exists,
60
# because the apache user may not have permission. Instead we have to
61
# rely on the interpreter generating an error.
62
if filename.startswith(os.sep):
63
filename_abs = filename
64
filename_rel = filename[1:]
66
filename_abs = os.path.join(os.sep, filename)
67
filename_rel = filename
69
# (Note: files are executed by their owners, not the logged in user.
70
# This ensures users are responsible for their own programs and also
71
# allows them to be executed by the public).
73
# Split up req.path again, this time with respect to the jail
74
(working_dir, _) = os.path.split(filename_abs)
75
# jail_dir is the absolute jail directory.
76
# path is the filename relative to the user's jail.
77
# working_dir is the directory containing the file relative to the user's
79
# (Note that paths "relative" to the jail actually begin with a '/' as
80
# they are absolute in the jailspace)
82
return interpreter(owner, jail_dir, working_dir, filename_abs, req,
83
gentle, overrides=overrides)
86
"""Stores flags regarding the state of reading CGI output.
87
If this is to be gentle, detection of invalid headers will result in an
89
def __init__(self, begentle=True):
90
self.gentle = begentle
91
self.started_cgi_body = False
92
self.got_cgi_headers = False
93
self.wrote_html_warning = False
95
self.headers = {} # Header names : values
97
def execute_cgi(interpreter, owner, jail_dir, working_dir, script_path,
98
req, gentle, overrides=None):
100
trampoline: Full path on the local system to the CGI wrapper program
102
owner: User object of the owner of the file.
103
jail_dir: Absolute path of owner's jail directory.
104
working_dir: Directory containing the script file relative to owner's
106
script_path: CGI script relative to the owner's jail.
107
req: IVLE request object.
109
overrides: A dict mapping env var names to strings, to override arbitrary
110
environment variables in the resulting CGI environent.
112
The called CGI wrapper application shall be called using popen and receive
113
the HTTP body on stdin. It shall receive the CGI environment variables to
117
trampoline = os.path.join(req.config['paths']['lib'], 'trampoline')
119
# Support no-op trampoline runs.
120
if interpreter is None:
121
interpreter = '/bin/true'
127
# Get the student program's directory and execute it from that context.
128
(tramp_dir, _) = os.path.split(trampoline)
130
# TODO: Don't create a file if the body length is known to be 0
131
# Write the HTTP body to a temporary file so it can be passed as a *real*
134
body = req.read() if not noop else None
138
f.seek(0) # Rewind, for reading
140
# Set up the environment
141
environ = cgi_environ(req, script_path, owner, overrides=overrides)
143
# usage: tramp uid jail_dir working_dir script_path
144
cmd_line = [trampoline, str(owner.unixid),
145
req.config['paths']['jails']['mounts'],
146
req.config['paths']['jails']['src'],
147
req.config['paths']['jails']['template'],
148
jail_dir, working_dir, interpreter, script_path]
149
# Popen doesn't like unicode strings. It hateses them.
150
cmd_line = [(s.encode('utf-8') if isinstance(s, unicode) else s)
152
pid = subprocess.Popen(cmd_line,
153
stdin=f, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
154
cwd=tramp_dir, env=environ)
156
# We don't want any output! Bail out after the process terminates.
161
# process_cgi_line: Reads a single line of CGI output and processes it.
162
# Prints to req, and also does fancy HTML warnings if Content-Type
164
cgiflags = CGIFlags(gentle)
166
# Read from the process's stdout into req
167
data = pid.stdout.read(CGI_BLOCK_SIZE)
169
process_cgi_output(req, data, cgiflags)
170
data = pid.stdout.read(CGI_BLOCK_SIZE)
172
# If we haven't processed headers yet, now is a good time
173
if not cgiflags.started_cgi_body:
174
process_cgi_output(req, '\n', cgiflags)
176
# If we wrote an HTML warning header, write the footer
177
if cgiflags.wrote_html_warning:
183
def process_cgi_output(req, data, cgiflags):
184
"""Processes a chunk of CGI output. data is a string of arbitrary length;
185
some arbitrary chunk of output written by the CGI script."""
186
if cgiflags.started_cgi_body:
187
if cgiflags.wrote_html_warning:
188
# HTML escape text if wrote_html_warning
189
req.write(cgi.escape(data))
193
# Break data into lines of CGI header data.
194
linebuf = cgiflags.linebuf + data
195
# First see if we can split all header data
196
# We need to get the double CRLF- or LF-terminated headers, whichever
197
# is smaller, as either sequence may appear somewhere in the body.
198
usplit = linebuf.split('\n\n', 1)
199
wsplit = linebuf.split('\r\n\r\n', 1)
200
split = len(usplit[0]) > len(wsplit[0]) and wsplit or usplit
202
# Haven't seen all headers yet. Buffer and come back later.
203
cgiflags.linebuf = linebuf
208
cgiflags.linebuf = ""
209
cgiflags.started_cgi_body = True
210
# Process all the header lines
211
split = headers.split('\r\n', 1)
213
split = headers.split('\n', 1)
215
process_cgi_header_line(req, split[0], cgiflags)
216
if len(split) == 1: break
218
if cgiflags.wrote_html_warning:
219
# We're done with headers. Treat the rest as data.
220
data = headers + '\n' + data
222
split = headers.split('\r\n', 1)
224
split = headers.split('\n', 1)
226
# If not executing in gentle mode (which presents CGI violations
227
# to users nicely), check if this an internal IVLE error
229
if not cgiflags.gentle:
230
hs = cgiflags.headers
231
if 'X-IVLE-Error-Type' in hs:
233
raise IVLEJailError(hs['X-IVLE-Error-Type'],
234
hs['X-IVLE-Error-Message'],
235
hs['X-IVLE-Error-Info'])
237
raise AssertionError("Bad error headers written by CGI.")
239
# Check to make sure the required headers were written
240
if cgiflags.wrote_html_warning or not cgiflags.gentle:
241
# We already reported an error, that's enough
243
elif "Content-Type" in cgiflags.headers:
245
elif "Location" in cgiflags.headers:
246
if ("Status" in cgiflags.headers and req.status >= 300
247
and req.status < 400):
250
message = """You did not write a valid status code for
251
the given location. To make a redirect, you may wish to try:</p>
252
<pre style="margin-left: 1em">Status: 302 Found
253
Location: <redirect address></pre>"""
254
write_html_warning(req, message)
255
cgiflags.wrote_html_warning = True
257
message = """You did not print a Content-Type header.
258
CGI requires that you print a "Content-Type". You may wish to try:</p>
259
<pre style="margin-left: 1em">Content-Type: text/html</pre>"""
260
write_html_warning(req, message)
261
cgiflags.wrote_html_warning = True
263
# Call myself to flush out the extra bit of data we read
264
process_cgi_output(req, data, cgiflags)
266
def process_cgi_header_line(req, line, cgiflags):
267
"""Process a line of CGI header data. line is a string representing a
268
complete line of text, stripped and without the newline.
271
name, value = line.split(':', 1)
273
# No colon. The user did not write valid headers.
274
# If we are being gentle, we want to help the user understand what
275
# went wrong. Otherwise, just admit we screwed up.
277
if not cgiflags.gentle:
278
message = """An unexpected server error has occured."""
280
elif len(cgiflags.headers) == 0:
281
# First line was not a header line. We can assume this is not
283
message = """You did not print a CGI header.
284
CGI requires that you print a "Content-Type". You may wish to try:</p>
285
<pre style="margin-left: 1em">Content-Type: text/html</pre>"""
287
# They printed some header at least, but there was an invalid
289
message = """You printed an invalid CGI header. You need to leave
290
a blank line after the headers, before writing the page contents."""
291
write_html_warning(req, message, warning=warning)
292
cgiflags.wrote_html_warning = True
293
# Handle the rest of this line as normal data
294
process_cgi_output(req, line + '\n', cgiflags)
297
# Check if CGI field-name is valid
298
CGI_SEPERATORS = set(['(', ')', '<', '>', '@', ',', ';', ':', '\\', '"',
299
'/', '[', ']', '?', '=', '{', '}', ' ', '\t'])
300
if any((char in CGI_SEPERATORS for char in name)):
302
if not cgiflags.gentle:
303
message = """An unexpected server error has occured."""
306
# Header contained illegal characters
307
message = """You printed an invalid CGI header. CGI header
308
field-names can not contain any of the following characters:
309
<code>( ) < > @ , ; : \\ " / [ ] ? = { } <em>SPACE
311
write_html_warning(req, message, warning=warning)
312
cgiflags.wrote_html_warning = True
313
# Handle the rest of this line as normal data
314
process_cgi_output(req, line + '\n', cgiflags)
318
value = value.strip()
319
if name == "Content-Type":
320
req.content_type = value
321
elif name == "Location":
323
elif name == "Status":
324
# Must be an integer, followed by a space, and then the status line
325
# which we ignore (seems like Apache has no way to send a custom
328
req.status = int(value.split(' ', 1)[0])
330
if not cgiflags.gentle:
331
# This isn't user code, so it should be good.
332
# Get us out of here!
334
message = """The "Status" CGI header was invalid. You need to
335
print a number followed by a message, such as "302 Found"."""
336
write_html_warning(req, message)
337
cgiflags.wrote_html_warning = True
338
# Handle the rest of this line as normal data
339
process_cgi_output(req, line + '\n', cgiflags)
341
# Generic HTTP header
342
# FIXME: Security risk letting users write arbitrary headers?
343
req.headers_out.add(name, value)
344
cgiflags.headers[name] = value # FIXME: Only the last header will end up here.
346
def write_html_warning(req, text, warning="Warning"):
347
"""Prints an HTML warning about invalid CGI interaction on the part of the
348
user. text may contain HTML markup."""
349
req.content_type = "text/html"
350
req.write("""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
351
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
352
<html xmlns="http://www.w3.org/1999/xhtml">
354
<meta http-equiv="Content-Type"
355
content="text/html; charset=utf-8" />
357
<body style="margin: 0; padding: 0; font-family: sans-serif;">
358
<div style="background-color: #faa; border-bottom: 1px solid black;
360
<p><strong>%s</strong>: %s
362
<div style="margin: 8px;">
364
""" % (warning, text))
366
# Mapping of interpreter names (as given in conf/app/server.py) to
367
# interpreter functions.
369
interpreter_objects = {
371
: functools.partial(execute_cgi, "/usr/bin/python"),
373
: functools.partial(execute_cgi, None),
379
def cgi_environ(req, script_path, user, overrides=None):
380
"""Gets CGI variables from apache and makes a few changes for security and
383
Does not modify req, only reads it.
385
overrides: A dict mapping env var names to strings, to override arbitrary
386
environment variables in the resulting CGI environent.
389
# Comments here are on the heavy side, explained carefully for security
390
# reasons. Please read carefully before making changes.
392
# This automatically asks mod_python to load up the CGI variables into the
393
# environment (which is a good first approximation)
394
for (k,v) in req.get_cgi_environ().items():
397
# Remove DOCUMENT_ROOT and SCRIPT_FILENAME. Not part of CGI spec and
398
# exposes unnecessary details about server.
400
del env['DOCUMENT_ROOT']
403
del env['SCRIPT_FILENAME']
406
# Remove PATH. The PATH here is the path on the server machine; not useful
407
# inside the jail. It may be a good idea to add another path, reflecting
408
# the inside of the jail, but not done at this stage.
413
# CGI specifies that REMOTE_HOST SHOULD be set, and MAY just be set to
414
# REMOTE_ADDR. Since Apache does not appear to set this, set it to
416
if 'REMOTE_HOST' not in env and 'REMOTE_ADDR' in env:
417
env['REMOTE_HOST'] = env['REMOTE_ADDR']
419
env['PATH_INFO'] = ''
420
del env['PATH_TRANSLATED']
422
normuri = os.path.normpath(req.uri)
423
env['SCRIPT_NAME'] = normuri
425
# SCRIPT_NAME is the path to the script WITHOUT PATH_INFO.
426
# We don't care about these if the script is null (ie. noop).
427
# XXX: We check for /home because we don't want to interfere with
428
# CGIRequest, which fileservice still uses.
429
if script_path and script_path.startswith('/home'):
430
normscript = os.path.normpath(script_path)
432
uri_into_jail = studpath.to_home_path(os.path.normpath(req.path))
434
# PATH_INFO is wrong because the script doesn't physically exist.
435
env['PATH_INFO'] = uri_into_jail[len(normscript):]
436
if len(env['PATH_INFO']) > 0:
437
env['SCRIPT_NAME'] = normuri[:-len(env['PATH_INFO'])]
439
# SERVER_SOFTWARE is actually not Apache but IVLE, since we are
440
# custom-making the CGI request.
441
env['SERVER_SOFTWARE'] = "IVLE/" + ivle.__version__
443
# Additional environment variables
444
username = user.login
445
env['HOME'] = os.path.join('/home', username)
447
if overrides is not None:
448
env.update(overrides)
451
class ExecutionError(Exception):
454
def execute_raw(config, user, jail_dir, working_dir, binary, args):
455
'''Execute a binary in a user's jail, returning the raw output.
457
The binary is executed in the given working directory with the given
458
args. A tuple of (stdout, stderr) is returned.
461
tramp = os.path.join(config['paths']['lib'], 'trampoline')
462
tramp_dir = os.path.split(tramp)[0]
464
# Fire up trampoline. Vroom, vroom.
465
cmd_line = [tramp, str(user.unixid), config['paths']['jails']['mounts'],
466
config['paths']['jails']['src'],
467
config['paths']['jails']['template'],
468
jail_dir, working_dir, binary] + args
469
# Popen doesn't like unicode strings. It hateses them.
470
cmd_line = [(s.encode('utf-8') if isinstance(s, unicode) else s)
472
proc = subprocess.Popen(cmd_line,
473
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
474
stderr=subprocess.PIPE, cwd=tramp_dir, close_fds=True,
475
env={'HOME': os.path.join('/home', user.login),
478
'LOGNAME': user.login})
480
(stdout, stderr) = proc.communicate()
481
exitcode = proc.returncode
484
raise ExecutionError('subprocess ended with code %d, stderr: "%s"' %
486
return (stdout, stderr)
488
def jail_call(req, cgi_script, script_name, query_string=None,
489
request_method="GET", extra_overrides=None):
491
Makes a call to a CGI script inside the jail from outside the jail.
492
This can be used to allow Python scripts to access jail-only functions and
493
data without having to perform a full API request.
495
req: A Request object (will not be written to or attributes modified).
496
cgi_script: Path to cgi script outside of jail.
497
eg: os.path.join(req.config['paths']['share'],
498
'services/fileservice')
499
script_name: Name to set as SCRIPT_NAME for the CGI environment.
501
query_string: Query string to set as QUERY_STRING for the CGI environment.
502
eg: "action=svnrepostat&path=/users/studenta/"
503
request_method: Method to set as REQUEST_METHOD for the CGI environment.
504
eg: "POST". Defaults to "GET".
505
extra_overrides: A dict mapping env var names to strings, to override
506
arbitrary environment variables in the resulting CGI environent.
508
Returns a triple (status_code, content_type, contents).
510
interp_object = interpreter_objects["cgi-python"]
511
user_jail_dir = os.path.join(req.config['paths']['jails']['mounts'],
514
"SCRIPT_NAME": script_name,
515
"QUERY_STRING": query_string,
516
"REQUEST_URI": "%s%s%s" % (script_name, "?" if query_string else "",
518
"REQUEST_METHOD": request_method,
520
if extra_overrides is not None:
521
overrides.update(extra_overrides)
522
result = DummyReq(req)
523
interpret_file(result, req.user, user_jail_dir, cgi_script, interp_object,
524
gentle=False, overrides=overrides)
525
return result.status, result.content_type, result.getvalue()
527
class DummyReq(StringIO.StringIO):
528
"""A dummy request object, built from a real request object, which can be
529
used like a req but doesn't mutate the existing request.
530
(Used for reading CGI responses as strings rather than forwarding their
531
output to the current request.)
533
def __init__(self, req):
534
StringIO.StringIO.__init__(self)
536
def get_cgi_environ(self):
537
return self._real_req.get_cgi_environ()
538
def __getattr__(self, name):
539
return getattr(self._real_req, name)