Skip to content

Commit

Permalink
restrict content-type, soft size limit
Browse files Browse the repository at this point in the history
  • Loading branch information
mdtanrikulu committed Sep 11, 2024
1 parent 788f0f2 commit a299286
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 49 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.0.0-alpha.3.ethers.6",
"version": "1.0.0",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.esm.js",
Expand Down
3 changes: 2 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { convertToRawSVG, getImageURI } from './getImageURI';
import { resolveURI } from './resolveURI';
import { createAgentAdapter, createCacheAdapter, fetch } from './fetch';
import { isCID } from './isCID';
import { isImageURI } from './isImageURI';
import { ALLOWED_IMAGE_MIMETYPES, isImageURI } from './isImageURI';

export {
ALLOWED_IMAGE_MIMETYPES,
BaseError,
assert,
convertToRawSVG,
Expand Down
130 changes: 83 additions & 47 deletions src/utils/isImageURI.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import axios from 'axios';
import axios, { AxiosError } from 'axios';
import { Buffer } from 'buffer/';

import { fetch } from './fetch';

function isURIEncoded(uri: string) {
export const ALLOWED_IMAGE_MIMETYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp',
'image/avif',
'image/heic',
'image/heif',
'image/jxl',
];

const MAX_FILE_SIZE = 300 * 1024 * 1024; // 300 MB

function isURIEncoded(uri: string): boolean {
try {
return uri !== decodeURIComponent(uri);
} catch {
return false;
}
}

async function isStreamAnImage(url: string) {
async function isStreamAnImage(url: string): Promise<boolean> {
try {
const source = axios.CancelToken.source();

const response = await fetch.get(url, {
responseType: 'arraybuffer',
headers: {
Expand All @@ -29,6 +43,14 @@ async function isStreamAnImage(url: string) {
},
});

if (response.headers['content-length']) {
const contentLength = parseInt(response.headers['content-length'], 10);
if (contentLength > MAX_FILE_SIZE) {
console.warn(`isStreamAnImage: File too large ${contentLength} bytes`);
return false;
}
}

let magicNumbers: string;
// Check the binary signature (magic numbers) of the data
if (response.data instanceof ArrayBuffer) {
Expand All @@ -41,9 +63,8 @@ async function isStreamAnImage(url: string) {
'ffd8ff', // JPEG
'89504e47', // PNG
'47494638', // GIF
'49492a00', // TIFF (little endian)
'4d4d002a', // TIFF (big endian)
'424d', // BMP
'ff0a', // JPEG XL
];

const isBinaryImage = imageSignatures.some(signature =>
Expand All @@ -67,47 +88,62 @@ async function isStreamAnImage(url: string) {
}
}

export function isImageURI(url: string) {
export async function isImageURI(url: string): Promise<boolean> {
const encodedURI = isURIEncoded(url) ? url : encodeURI(url);

return new Promise(resolve => {
fetch({ url: encodedURI, method: 'HEAD' })
.then(result => {
if (result.status === 200) {
// retrieve content type header to check if content is image
const contentType = result.headers['content-type'];

if (contentType?.startsWith('application/octet-stream')) {
// if image served with generic mimetype, do additional check
resolve(isStreamAnImage(encodedURI));
}

resolve(contentType?.startsWith('image/'));
} else {
resolve(false);
}
})
.catch(error => {
console.warn('isImageURI: fetch error', error);
// if error is not cors related then fail
if (typeof error.response !== 'undefined') {
// in case of cors, use image api to validate if given url is an actual image
resolve(false);
return;
}
if (!globalThis.hasOwnProperty('Image')) {
// fail in NodeJS, since the error is not cors but any other network issue
resolve(false);
return;
}
const img = new Image();
img.onload = () => {
resolve(true);
};
img.onerror = () => {
resolve(false);
};
img.src = encodedURI;
});
});
try {
const result = await fetch({ url: encodedURI, method: 'HEAD' });

if (result.status === 200) {
const contentType = result.headers['content-type']?.toLowerCase();

if (!contentType || !ALLOWED_IMAGE_MIMETYPES.includes(contentType)) {
console.warn(`isImageURI: Invalid content type ${contentType}`);
return false;
}

const contentLength = parseInt(
result.headers['content-length'] || '0',
10
);
if (contentLength > MAX_FILE_SIZE) {
console.warn(`isImageURI: File too large ${contentLength} bytes`);
return false;
}

if (contentType === 'application/octet-stream') {
// if image served with generic mimetype, do additional check
return isStreamAnImage(encodedURI);
}

return true;
} else {
console.warn(`isImageURI: HTTP error ${result.status}`);
return false;
}
} catch (error) {
if (error instanceof AxiosError) {
console.warn('isImageURI: ', error.toString(), '-', error.config.url);
} else {
console.warn('isImageURI: ', error.toString());
}

// if error is not cors related then fail
if (typeof error.response !== 'undefined') {
// in case of cors, use image api to validate if given url is an actual image
return false;
}

if (!globalThis.hasOwnProperty('Image')) {
// fail in NodeJS, since the error is not cors but any other network issue
return false;
}

return new Promise<boolean>(resolve => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = encodedURI;
});
}
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
"noUnusedLocals": false,
"noUnusedParameters": true,
"useUnknownInCatchVariables": false,
// use Node's module resolution algorithm, instead of the legacy TS one
"moduleResolution": "node",
// transpile JSX to React.createElement
Expand Down

0 comments on commit a299286

Please sign in to comment.