Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/core/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Utils } from '../utils.js';
* @param {Array} grid - Grid data from imported file
* @returns {Array} Sanitized grid with all null/undefined converted to 0
*/
function sanitizeGrid(grid) {
export function sanitizeGrid(grid) {
if (!Array.isArray(grid)) {
return grid;
}
Expand Down
6 changes: 4 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { StorageManager } from './managers/storage.js';
import { HistoryManager } from './managers/history.js';
import { CanvasManager } from './managers/canvas.js';
import { createEmptyGrid, resizeGrid, resizeGridFromEdge } from './core/grid.js';
import { exportSvg, exportPng, exportPreviewSvg, exportPreviewPng, exportPatternWithContextSvg, exportPatternWithContextPng, exportJson, importJson, downloadFile } from './core/export.js';
import { exportSvg, exportPng, exportPreviewSvg, exportPreviewPng, exportPatternWithContextSvg, exportPatternWithContextPng, exportJson, importJson, downloadFile, sanitizeGrid } from './core/export.js';
import { generateShareUrl, parseShareUrl, copyToClipboard, validateShareData } from './utils/sharing.js';
import {
validateGridDimension,
Expand Down Expand Up @@ -1849,7 +1849,9 @@ if (!shareUrlResult.success) {
CONFIG.MAX_ASPECT_RATIO,
CONFIG.DEFAULT_ASPECT_RATIO
);
grid = patternData.grid.cells || createEmptyGrid(gridWidth, gridHeight);
grid = patternData.grid.cells
? sanitizeGrid(patternData.grid.cells)
: createEmptyGrid(gridWidth, gridHeight);
backgroundColor = patternData.colors.background || CONFIG.DEFAULT_BACKGROUND_COLOR;
patternColors = patternData.colors.pattern || [CONFIG.DEFAULT_PATTERN_COLOR];
activePatternIndex = 0;
Expand Down
45 changes: 41 additions & 4 deletions src/utils/sharing.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import LZString from 'lz-string';
import { exportJson } from '../core/export.js';
import { HEX_COLOR_PATTERN } from './validation.js';

// URL format constants
const SHARE_URL_PREFIX = '#p=';
Expand Down Expand Up @@ -153,15 +154,51 @@ export async function copyToClipboard(text) {
}
}

function isHexArray(arr) {
if (!Array.isArray(arr)) return false;
for (const c of arr) {
if (typeof c !== 'string' || !HEX_COLOR_PATTERN.test(c)) return false;
}
return true;
}

/**
* Validate share URL data against schema
* Validate share URL data against schema. Strict: rejects anything whose
* shape or types don't match the export format, so attacker-controlled
* share URLs can't smuggle non-string values into color sinks (SVG export,
* style.backgroundColor) or non-numeric values into the grid.
* @param {Object} data - Parsed share data
* @returns {boolean} - Whether data is valid
*/
export function validateShareData(data) {
if (!data || typeof data !== 'object') return false;
if (!data.version || data.version !== 1) return false;
if (!data.grid || !data.colors) return false;
if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
if (data.version !== 1) return false;

const { grid, colors } = data;
if (!grid || typeof grid !== 'object' || Array.isArray(grid)) return false;
if (!colors || typeof colors !== 'object' || Array.isArray(colors)) return false;

if (typeof grid.width !== 'number' || !Number.isFinite(grid.width)) return false;
if (typeof grid.height !== 'number' || !Number.isFinite(grid.height)) return false;
if (grid.aspectRatio !== undefined &&
(typeof grid.aspectRatio !== 'number' || !Number.isFinite(grid.aspectRatio))) return false;
if (grid.cells !== undefined) {
if (!Array.isArray(grid.cells)) return false;
for (const row of grid.cells) {
if (!Array.isArray(row)) return false;
}
}

if (typeof colors.background !== 'string' || !HEX_COLOR_PATTERN.test(colors.background)) return false;
if (!isHexArray(colors.pattern) || colors.pattern.length === 0) return false;

// Optional custom palette flows into patternColors (which reach SVG string
// concatenation), so each entry must also be a hex string.
if (data.palette !== undefined) {
if (!data.palette || typeof data.palette !== 'object' || Array.isArray(data.palette)) return false;
if (data.palette.custom != null && !isHexArray(data.palette.custom)) return false;
}

return true;
}

Expand Down
10 changes: 6 additions & 4 deletions src/utils/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,15 @@ export function validatePreviewRepeat(input, fieldName = 'Preview repeat') {
* @param {string} input - The color value to validate
* @returns {ValidationResult}
*/
// Matches #RGB, #RRGGBB, or #RRGGBBAA. Exported so security-sensitive
// callers (e.g. share-URL validation) can reject non-hex strings at the
// boundary without re-declaring the regex.
export const HEX_COLOR_PATTERN = /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/;

export function validateColor(input) {
const trimmed = input.trim();

// Match #RGB, #RRGGBB, or #RRGGBBAA
const hexPattern = /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/;

if (!hexPattern.test(trimmed)) {
if (!HEX_COLOR_PATTERN.test(trimmed)) {
return {
valid: false,
value: CONFIG.DEFAULT_PATTERN_COLOR,
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/sharing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,50 @@ describe('Sharing Utilities', () => {
expect(validateShareData(123)).toBe(false);
expect(validateShareData([])).toBe(false);
});

it('should reject non-hex colors', () => {
const base = {
version: 1,
grid: { width: 5, height: 5, cells: [[0]] },
colors: { background: '#ffffff', pattern: ['#000000'] }
};
expect(validateShareData({ ...base, colors: { background: 'red', pattern: ['#000000'] } })).toBe(false);
expect(validateShareData({ ...base, colors: { background: '#ffffff', pattern: ['"/><script>x</script>'] } })).toBe(false);
expect(validateShareData({ ...base, colors: { background: '#ffffff', pattern: ['#zzz'] } })).toBe(false);
expect(validateShareData({ ...base, colors: { background: '#ffffff', pattern: [] } })).toBe(false);
});

it('should reject non-numeric grid dimensions', () => {
const base = {
version: 1,
grid: { width: 5, height: 5, cells: [[0]] },
colors: { background: '#ffffff', pattern: ['#000000'] }
};
expect(validateShareData({ ...base, grid: { ...base.grid, width: '5' } })).toBe(false);
expect(validateShareData({ ...base, grid: { ...base.grid, height: NaN } })).toBe(false);
expect(validateShareData({ ...base, grid: { ...base.grid, aspectRatio: 'wide' } })).toBe(false);
});

it('should reject malformed grid cells', () => {
const base = {
version: 1,
grid: { width: 5, height: 5 },
colors: { background: '#ffffff', pattern: ['#000000'] }
};
expect(validateShareData({ ...base, grid: { ...base.grid, cells: 'oops' } })).toBe(false);
expect(validateShareData({ ...base, grid: { ...base.grid, cells: [1, 2, 3] } })).toBe(false);
});

it('should reject custom palette with non-hex entries', () => {
const base = {
version: 1,
grid: { width: 5, height: 5, cells: [[0]] },
colors: { background: '#ffffff', pattern: ['#000000'] }
};
expect(validateShareData({ ...base, palette: { custom: ['#abc', 'javascript:alert(1)'] } })).toBe(false);
expect(validateShareData({ ...base, palette: { custom: ['#abc', '#def'] } })).toBe(true);
expect(validateShareData({ ...base, palette: { custom: null } })).toBe(true);
});
});

describe('copyToClipboard()', () => {
Expand Down
Loading