luci/modules/luci-base/ucode/http.uc
Jo-Philipp Wich b4594b3d11 luci-base: http.uc: fix eof detection in temporary upload files
Binary string comparisons in ucode are currently unsafe, so use the
`length()` function to determine the just read data chunk size in
order to test for end of file.

Fixes: #6530
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
2023-08-21 16:37:11 +02:00

574 lines
13 KiB
Ucode

// Copyright 2022 Jo-Philipp Wich <jo@mein.io>
// Licensed to the public under the Apache License 2.0.
import {
urlencode as _urlencode,
urldecode as _urldecode,
urlencoded_parser, multipart_parser, header_attribute,
ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS
} from 'lucihttp';
import {
error as fserror,
stdin, stdout, mkstemp
} from 'fs';
// luci.http module scope
export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size
// Decode a mime encoded http message body with multipart/form-data
// Content-Type. Stores all extracted data associated with its parameter name
// in the params table within the given message object. Multiple parameter
// values are stored as tables, ordinary ones as strings.
// If an optional file callback function is given then it is fed with the
// file contents chunk by chunk and only the extracted file name is stored
// within the params table. The callback function will be called subsequently
// with three arguments:
// o Table containing decoded (name, file) and raw (headers) mime header data
// o String value containing a chunk of the file data
// o Boolean which indicates whether the current chunk is the last one (eof)
export function mimedecode_message_body(src, msg, file_cb) {
let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
let err, header, field, parser;
parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) {
if (what == parser.PART_INIT) {
field = {};
}
else if (what == parser.HEADER_NAME) {
header = lc(buffer);
}
else if (what == parser.HEADER_VALUE && header) {
if (lc(header) == 'content-disposition' &&
header_attribute(buffer, null) == 'form-data') {
field.name = header_attribute(buffer, 'name');
field.file = header_attribute(buffer, 'filename');
field[1] = field.file;
}
field.headers = field.headers || {};
field.headers[header] = buffer;
}
else if (what == parser.PART_BEGIN) {
return !field.file;
}
else if (what == parser.PART_DATA && field.name && length > 0) {
if (field.file) {
if (file_cb) {
file_cb(field, buffer, false);
msg.params[field.name] = msg.params[field.name] || field;
}
else {
if (!field.fd)
field.fd = mkstemp(field.name);
if (field.fd) {
field.fd.write(buffer);
msg.params[field.name] = msg.params[field.name] || field;
}
}
}
else {
field.value = buffer;
}
}
else if (what == parser.PART_END && field.name) {
if (field.file && msg.params[field.name]) {
if (file_cb)
file_cb(field, '', true);
else if (field.fd)
field.fd.seek(0);
}
else {
let val = msg.params[field.name];
if (type(val) == 'array')
push(val, field.value || '');
else if (val != null)
msg.params[field.name] = [ val, field.value || '' ];
else
msg.params[field.name] = field.value || '';
}
field = null;
}
else if (what == parser.ERROR) {
err = buffer;
}
return true;
}, HTTP_MAX_CONTENT);
while (true) {
let chunk = src();
len += length(chunk);
if (maxlen && len > maxlen + 2)
die('Message body size exceeds Content-Length');
if (!parser.parse(chunk))
die(err);
if (chunk == null)
break;
}
};
// Decode an urlencoded http message body with application/x-www-urlencoded
// Content-Type. Stores all extracted data associated with its parameter name
// in the params table within the given message object. Multiple parameter
// values are stored as tables, ordinary ones as strings.
export function urldecode_message_body(src, msg) {
let len = 0, maxlen = +msg.env.CONTENT_LENGTH;
let err, name, value, parser;
parser = urlencoded_parser(function (what, buffer, length) {
if (what == parser.TUPLE) {
name = null;
value = null;
}
else if (what == parser.NAME) {
name = _urldecode(buffer, DECODE_PLUS);
}
else if (what == parser.VALUE && name) {
let val = msg.params[name];
if (type(val) == 'array')
push(val, _urldecode(buffer, DECODE_PLUS) || '');
else if (val != null)
msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ];
else
msg.params[name] = _urldecode(buffer, DECODE_PLUS) || '';
}
else if (what == parser.ERROR) {
err = buffer;
}
return true;
}, HTTP_MAX_CONTENT);
while (true) {
let chunk = src();
len += length(chunk);
if (maxlen && len > maxlen + 2)
die('Message body size exceeds Content-Length');
if (!parser.parse(chunk))
die(err);
if (chunk == null)
break;
}
};
// This function will examine the Content-Type within the given message object
// to select the appropriate content decoder.
// Currently the application/x-www-urlencoded and application/form-data
// mime types are supported. If the encountered content encoding can't be
// handled then the whole message body will be stored unaltered as 'content'
// property within the given message object.
export function parse_message_body(src, msg, filecb) {
if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') {
let ctype = header_attribute(msg.env.CONTENT_TYPE, null);
// Is it multipart/mime ?
if (ctype == 'multipart/form-data')
return mimedecode_message_body(src, msg, filecb);
// Is it application/x-www-form-urlencoded ?
else if (ctype == 'application/x-www-form-urlencoded')
return urldecode_message_body(src, msg);
// Unhandled encoding
// If a file callback is given then feed it chunk by chunk, else
// store whole buffer in message.content
let sink;
// If we have a file callback then feed it
if (type(filecb) == 'function') {
let meta = {
name: 'raw',
encoding: msg.env.CONTENT_TYPE
};
sink = (chunk) => {
if (chunk != null)
return filecb(meta, chunk, false);
else
return filecb(meta, null, true);
};
}
// ... else append to .content
else {
let chunks = [], len = 0;
sink = (chunk) => {
len += length(chunk);
if (len > HTTP_MAX_CONTENT)
die('POST data exceeds maximum allowed length');
if (chunk != null) {
push(chunks, chunk);
}
else {
msg.content = join('', chunks);
msg.content_length = len;
}
};
}
// Pump data...
while (true) {
let chunk = src();
sink(chunk);
if (chunk == null)
break;
}
return true;
}
return false;
};
export function build_querystring(q) {
let s = [];
for (let k, v in q) {
push(s,
length(s) ? '&' : '?',
_urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k,
'=',
_urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v
);
}
return join('', s);
};
export function urlencode(value) {
if (value == null)
return null;
value = '' + value;
return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value;
};
export function urldecode(value, decode_plus) {
if (value == null)
return null;
value = '' + value;
return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value;
};
// Extract and split urlencoded data pairs, separated bei either "&" or ";"
// from given url or string. Returns a table with urldecoded values.
// Simple parameters are stored as string values associated with the parameter
// name within the table. Parameters with multiple values are stored as array
// containing the corresponding values.
export function urldecode_params(url, tbl) {
let parser, name, value;
let params = tbl || {};
parser = urlencoded_parser(function(what, buffer, length) {
if (what == parser.TUPLE) {
name = null;
value = null;
}
else if (what == parser.NAME) {
name = _urldecode(buffer);
}
else if (what == parser.VALUE && name) {
params[name] = _urldecode(buffer) || '';
}
return true;
});
if (parser) {
let m = match(('' + (url || '')), /[^?]*$/);
parser.parse(m ? m[0] : '');
parser.parse(null);
}
return params;
};
// Encode each key-value-pair in given table to x-www-urlencoded format,
// separated by '&'. Tables are encoded as parameters with multiple values by
// repeating the parameter name with each value.
export function urlencode_params(tbl) {
let enc = [];
for (let k, v in tbl) {
if (type(v) == 'array') {
for (let v2 in v) {
if (length(enc))
push(enc, '&');
push(enc,
_urlencode(k),
'=',
_urlencode('' + v2));
}
}
else {
if (length(enc))
push(enc, '&');
push(enc,
_urlencode(k),
'=',
_urlencode('' + v));
}
}
return join(enc, '');
};
// Default IO routines suitable for CGI invocation
let avail_len = +getenv('CONTENT_LENGTH');
const default_source = () => {
let rlen = min(avail_len, 4096);
if (rlen == 0) {
stdin.close();
return null;
}
let chunk = stdin.read(rlen);
if (chunk == null)
die(`Input read error: ${fserror()}`);
avail_len -= length(chunk);
return chunk;
};
const default_sink = (...chunks) => {
for (let chunk in chunks)
stdout.write(chunk);
stdout.flush();
};
const Class = {
formvalue: function(name, noparse) {
if (!noparse && !this.parsed_input)
this._parse_input();
if (name != null)
return this.message.params[name];
else
return this.message.params;
},
formvaluetable: function(prefix) {
let vals = {};
prefix = (prefix || '') + '.';
if (!this.parsed_input)
this._parse_input();
for (let k, v in this.message.params)
if (index(k, prefix) == 0)
vals[substr(k, length(prefix))] = '' + v;
return vals;
},
content: function() {
if (!this.parsed_input)
this._parse_input();
return this.message.content;
},
getcookie: function(name) {
return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name);
},
getenv: function(name) {
if (name != null)
return this.message.env[name];
else
return this.message.env;
},
setfilehandler: function(callback) {
if (type(callback) == 'resource' && type(callback.call) == 'function')
this.filehandler = (...args) => callback.call(...args);
else if (type(callback) == 'function')
this.filehandler = callback;
else
die('Invalid callback argument for setfilehandler()');
if (!this.parsed_input)
return;
// If input has already been parsed then uploads are stored as unlinked
// temporary files pointed to by open file handles in the parameter
// value table. Loop all params, and invoke the file callback for any
// param with an open file handle.
for (let name, value in this.message.params) {
while (value?.fd) {
let data = value.fd.read(1024);
let eof = (length(data) == 0);
this.filehandler(value, data, eof);
if (eof) {
value.fd.close();
value.fd = null;
}
}
}
},
_parse_input: function() {
parse_message_body(
this.input,
this.message,
this.filehandler
);
this.parsed_input = true;
},
close: function() {
this.write_headers();
this.closed = true;
},
header: function(key, value) {
this.headers ??= {};
this.headers[lc(key)] = value;
},
prepare_content: function(mime) {
if (!this.headers?.['content-type']) {
if (mime == 'application/xhtml+xml') {
if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) {
mime = 'text/html; charset=UTF-8';
this.header('Vary', 'Accept');
}
}
this.header('Content-Type', mime);
}
},
status: function(code, message) {
this.status_code = code ?? 200;
this.status_message = message ?? 'OK';
},
write_headers: function() {
if (this.eoh)
return;
if (!this.status_code)
this.status();
if (!this.headers?.['content-type'])
this.header('Content-Type', 'text/html; charset=UTF-8');
if (!this.headers?.['cache-control']) {
this.header('Cache-Control', 'no-cache');
this.header('Expires', '0');
}
if (!this.headers?.['x-frame-options'])
this.header('X-Frame-Options', 'SAMEORIGIN');
if (!this.headers?.['x-xss-protection'])
this.header('X-XSS-Protection', '1; mode=block');
if (!this.headers?.['x-content-type-options'])
this.header('X-Content-Type-Options', 'nosniff');
this.output('Status: ');
this.output(this.status_code);
this.output(' ');
this.output(this.status_message);
this.output('\r\n');
for (let k, v in this.headers) {
this.output(k);
this.output(': ');
this.output(v);
this.output('\r\n');
}
this.output('\r\n');
this.eoh = true;
},
// If the content chunk is nil this function will automatically invoke close.
write: function(content) {
if (content != null && !this.closed) {
this.write_headers();
this.output(content);
return true;
}
else {
this.close();
}
},
redirect: function(url) {
this.status(302, 'Found');
this.header('Location', url ?? '/');
this.close();
},
write_json: function(value) {
this.write(sprintf('%.J', value));
},
urlencode,
urlencode_params,
urldecode,
urldecode_params,
build_querystring
};
export default function(env, sourcein, sinkout) {
return proto({
input: sourcein ?? default_source,
output: sinkout ?? default_sink,
// File handler nil by default to let .content() work
file: null,
// HTTP-Message table
message: {
env,
headers: {},
params: urldecode_params(env?.QUERY_STRING ?? '')
},
parsed_input: false
}, Class);
};