Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/fileSystem/externalFs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import loader from "dialogs/loader";
import { decode, encode, getEncodingName } from "utils/encodings";
import helpers from "utils/helpers";
import Url from "utils/Url";
import { decodeReadRange, validateReadRange } from "./readRange";

const externalFs = {
async readFile(url) {
Expand All @@ -11,6 +12,21 @@ const externalFs = {
});
},

async readFileRange(url, start, end) {
const range = validateReadRange(start, end);
if (range.length === 0) return { data: new ArrayBuffer(0) };
url = await this.formatUri(url);
return new Promise((resolve, reject) => {
sdcard.readRange(
url,
range.start,
range.end,
(data) => resolve({ data }),
reject,
);
});
},

async readAsText(url, encoding) {
url = await this.formatUri(url);
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -203,6 +219,10 @@ function createFs(url) {
let { data } = await externalFs.readFile(url);
return data;
},
async readFileRange(start, end, encoding) {
const { data } = await externalFs.readFileRange(url, start, end);
return decodeReadRange(data, encoding);
},
Comment on lines +222 to +225

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Range reads retain JSON BOM

When a range contains an entire BOM-prefixed JSON document and uses the json encoding, this path passes the BOM directly to JSON decoding instead of applying the normalization used by readFile, causing the range read to reject for a document the full-file path accepts.

Knowledge Base Used: File System

async writeFile(content, encoding) {
if (typeof content === "string" && encoding) {
const charset = getEncodingName(
Expand Down
43 changes: 43 additions & 0 deletions src/fileSystem/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Url from "utils/Url";
import externalFs from "./externalFs";
import Ftp from "./ftp";
import internalFs from "./internalFs";
import { decodeReadRange, validateReadRange } from "./readRange";
import Sftp from "./sftp";

const fsList = [];
Expand Down Expand Up @@ -39,6 +40,7 @@ const fsList = [];
* @property {() => Promise<boolean>} exists Check if file or directory exists
* @property {() => Promise<Stat>} stat Get file or directory stat
* @property {(encoding:string) => Promise<FileContent>} readFile Read file
* @property {(start:number, end:number, encoding?:string) => Promise<FileContent>} [readFileRange] Read a half-open byte range without loading the whole file
* @property {(data:FileContent, encoding: string) => Promise<void>} writeFile Write file content
* @property {(name:string, data:FileContent) => Promise<string>} createFile Create file and return url of the created file
* @property {(name:string) => Promise<string>} createDirectory Create directory and return url of the created directory
Expand Down Expand Up @@ -94,6 +96,47 @@ fsOperation.extend(

return data;
},
async readFileRange(start, end, encoding) {
const range = validateReadRange(start, end);
if (range.length === 0) {
return decodeReadRange(new ArrayBuffer(0), encoding);
}

const response = await fetch(url, {
headers: {
Range: `bytes=${range.start}-${range.end - 1}`,
},
});

if (response.status !== 206) {
await response.body?.cancel();
throw new Error("HTTP server does not support byte-range reads");
}

const contentRange = response.headers.get("content-range");
const returnedRange = contentRange?.match(/^bytes (\d+)-(\d+)\//i);
if (
returnedRange &&
(Number(returnedRange[1]) !== range.start ||
Number(returnedRange[2]) >= range.end)
) {
await response.body?.cancel();
throw new Error("HTTP server returned a different byte range");
}

const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > range.length) {
await response.body?.cancel();
throw new Error("HTTP server returned more data than requested");
}

const data = await response.arrayBuffer();
if (data.byteLength > range.length) {
Comment on lines +133 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Unbounded HTTP response buffering

When a server returns 206 without a valid Content-Length or Content-Range and streams more bytes than requested, response.arrayBuffer() buffers the entire body before the length check, causing WebView memory exhaustion despite the bounded-read contract.

How this was verified: The optional header guards can be skipped, while the response-size check runs only after the complete body has been buffered.

Knowledge Base Used: File System

throw new Error("HTTP server returned more data than requested");
}

return decodeReadRange(data, encoding);
},
async writeFile(content, progress) {
return ajax.post(url, {
data: content,
Expand Down
34 changes: 34 additions & 0 deletions src/fileSystem/internalFs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ajax from "lib/ajax";
import { decode, encode } from "utils/encodings";
import helpers from "utils/helpers";
import Url from "utils/Url";
import { decodeReadRange, validateReadRange } from "./readRange";

const internalFs = {
/**
Expand Down Expand Up @@ -122,6 +123,35 @@ const internalFs = {
});
},

/**
* Read a half-open byte range without loading the complete file.
* @param {string} filename
* @param {number} start Inclusive byte offset
* @param {number} end Exclusive byte offset
* @returns {Promise<{data: ArrayBuffer}>}
*/
readFileRange(filename, start, end) {
const range = validateReadRange(start, end);
if (range.length === 0) {
return Promise.resolve({ data: new ArrayBuffer(0) });
}
return new Promise((resolve, reject) => {
reject = setMessage(reject);
window.resolveLocalFileSystemURL(
filename,
(fileEntry) => {
fileEntry.file((file) => {
const fileReader = new FileReader();
fileReader.onerror = () => reject(fileReader.error);
fileReader.onload = () => resolve({ data: fileReader.result });
fileReader.readAsArrayBuffer(file.slice(range.start, range.end));
}, reject);
},
reject,
);
});
},

/**
* Rename a file or directory
* @param {string} url
Expand Down Expand Up @@ -413,6 +443,10 @@ function createFs(url) {

return data;
},
async readFileRange(start, end, encoding) {
const { data } = await internalFs.readFileRange(url, start, end);
return decodeReadRange(data, encoding);
},
async writeFile(content, encoding) {
if (typeof content === "string" && encoding) {
content = await encode(content, encoding);
Expand Down
43 changes: 43 additions & 0 deletions src/fileSystem/readRange.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { decode } from "utils/encodings";

// Native bridges return byte arrays, whose length is limited to a signed int.
export const MAX_READ_RANGE_LENGTH = 0x7fffffff;

/**
* Validate a half-open byte range used by filesystem implementations.
*
* @param {number} start Inclusive byte offset
* @param {number} end Exclusive byte offset
* @returns {{start: number, end: number, length: number}}
*/
export function validateReadRange(start, end) {
if (!Number.isSafeInteger(start) || start < 0) {
throw new RangeError("Range start must be a non-negative safe integer");
}

if (!Number.isSafeInteger(end) || end < start) {
throw new RangeError(
"Range end must be a safe integer greater than or equal to start",
);
}

const length = end - start;
if (length > MAX_READ_RANGE_LENGTH) {
throw new RangeError(
`Range length must not exceed ${MAX_READ_RANGE_LENGTH} bytes`,
);
}

return { start, end, length };
}

/**
* Decode range data only after the backend has performed a bounded byte read.
*
* @param {ArrayBuffer} data
* @param {string} [encoding]
* @returns {Promise<ArrayBuffer|string|object>}
*/
export async function decodeReadRange(data, encoding) {
return encoding ? decode(data, encoding) : data;
}
34 changes: 34 additions & 0 deletions src/fileSystem/sftp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import helpers from "utils/helpers";
import Path from "utils/Path";
import Url from "utils/Url";
import internalFs from "./internalFs";
import { decodeReadRange, validateReadRange } from "./readRange";

let pendingConnection = null;
let pendingConnectionID = null;
Expand Down Expand Up @@ -210,6 +211,22 @@ class SftpClient {
});
}

/** Read a half-open byte range directly from the remote file. */
async readFileRange(start, end) {
const range = validateReadRange(start, end);
if (range.length === 0) return { data: new ArrayBuffer(0) };
await this.#connectIfNotConnected();
return new Promise((resolve, reject) => {
sftp.getFileRange(
this.#safeName(this.#path),
range.start,
range.end,
(data) => resolve({ data }),
reject,
);
});
}

async copyTo(dest) {
const src = this.#path;
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -585,6 +602,19 @@ class SftpClient {
);
}

async #connectIfNotConnected() {
return new Promise((resolve, reject) => {
sftp.isConnected(async (connectionID) => {
try {
if (this.#notConnected(connectionID)) await this.connect();
resolve();
} catch (error) {
reject(error);
}
}, reject);
});
}

async #setStat() {
if (!this.#stat) {
this.#stat = await this.stat();
Expand Down Expand Up @@ -635,6 +665,10 @@ function createFs(sftp) {

return data;
},
async readFileRange(start, end, encoding) {
const { data } = await sftp.readFileRange(start, end);
return decodeReadRange(data, encoding);
},
async writeFile(content, encoding) {
if (typeof content === "string" && encoding) {
content = await encode(content, encoding);
Expand Down
8 changes: 8 additions & 0 deletions src/plugins/sdcard/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ interface SDcard {
onSuccess: (url: string) => void,
onFail: (err: any) => void,
): void;
/** Reads the half-open byte range [start, end) without loading the whole file. */
readRange(
src: string,
start: number,
end: number,
onSuccess: (data: ArrayBuffer) => void,
onFail: (err: any) => void,
): void;
/**
* Checks if given file/directory
* @param src File/Directory url
Expand Down
85 changes: 85 additions & 0 deletions src/plugins/sdcard/src/android/SDcard.java
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ public boolean execute(
case "read":
readFile(arg1, callback);
break;
case "readRange":
readFileRange(
arg1,
args.optLong(1, -1),
args.optLong(2, -1),
callback
);
break;
case "readAsText":
readAsText(arg1, arg2, callback);
break;
Expand Down Expand Up @@ -497,6 +505,83 @@ public void run() {
);
}

private void readFileRange(
String filename,
long start,
long end,
CallbackContext callback
) {
cordova
.getThreadPool()
.execute(
new Runnable() {
public void run() {
if (start < 0 || end < start || end - start > Integer.MAX_VALUE) {
callback.error("Invalid byte range");
return;
}

Uri uri = Uri.parse(formatUri(filename));
try (
InputStream input = context
.getContentResolver()
.openInputStream(uri)
) {
if (input == null) {
callback.error("File not found");
return;
}

if (!skipFully(input, start)) {
callback.success(new byte[0]);
return;
}

callback.success(readAtMost(input, end - start));
} catch (Exception e) {
callback.error(e.toString());
}
}
}
);
}

private static boolean skipFully(InputStream input, long count)
throws IOException {
long remaining = count;
while (remaining > 0) {
long skipped = input.skip(remaining);
if (skipped > 0) {
remaining -= skipped;
} else if (input.read() == -1) {
return false;
} else {
remaining--;
}
}
return true;
}

private static byte[] readAtMost(InputStream input, long count)
throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream(
(int) Math.min(count, 32768)
);
byte[] buffer = new byte[32768];
long remaining = count;
while (remaining > 0) {
int bytesRead = input.read(
buffer,
0,
(int) Math.min(buffer.length, remaining)
);
if (bytesRead == -1) break;
output.write(buffer, 0, bytesRead);
remaining -= bytesRead;
}
return output.toByteArray();
}

private void readAsText(final String filename, final String encoding, final CallbackContext callback) {
cordova
.getThreadPool()
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/sdcard/www/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ module.exports = {
read: function (filename, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'SDcard', 'read', [filename]);
},
readRange: function (filename, start, end, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'SDcard', 'readRange', [filename, String(start), String(end)]);
},
readAsText: function (filename, encoding, onSuccess, onFail) {
cordova.exec(onSuccess, onFail, 'SDcard', 'readAsText', [filename, encoding]);
},
Expand Down
Loading