Skip to content
Merged
6 changes: 4 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ updates:
target-branch: "staging"
open-pull-requests-limit: 5
auto-merge: true
# Group all patch updates together to reduce PR spam
# Group all patch and minor updates together to reduce PR spam
# and avoid races between back-to-back auto-merges on staging.
groups:
patch-updates:
dev-updates:
patterns:
- "*"
update-types:
- "patch"
- "minor"
# Automatically add labels
labels:
- "dependencies"
Expand Down
18 changes: 15 additions & 3 deletions .github/workflows/dependabot-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
workflows: ["Dependabot Auto-merge to Staging"]
types: [completed]

concurrency:
group: dependabot-deploy
cancel-in-progress: false

permissions:
contents: write
pull-requests: write
Expand All @@ -31,10 +35,18 @@ jobs:
--head staging \
--title "chore: Dependabot dependency updates" \
--body "Automated merge of Dependabot dependency updates from staging to main.")
gh pr merge --auto --squash "$PR_URL"
else
echo "PR #$EXISTING_PR already exists, enabling auto-merge"
gh pr merge --auto --squash "$EXISTING_PR"
echo "PR #$EXISTING_PR already exists"
PR_URL="$EXISTING_PR"
fi

# Try an immediate merge first; fall back to --auto if the PR isn't
# mergeable yet (e.g. CI still running). `gh pr merge --auto` silently
# no-ops when no required check is pending, so relying on it alone
# can leave the PR stuck open indefinitely.
if ! gh pr merge --squash "$PR_URL" 2>/dev/null; then
echo "Immediate merge not possible, queueing auto-merge"
gh pr merge --auto --squash "$PR_URL"
fi

# Wait for auto-merge to complete (CI must pass first)
Expand Down
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