Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bb93115
test(image): define decoded-size preflight RED
seonghobae Aug 10, 2026
5135b72
test(image): reach decoded-size RED boundary
seonghobae Aug 10, 2026
edcaf2e
fix(image): bound decoded-size validation
seonghobae Aug 10, 2026
0825761
test(images): reject malformed public byte limits
seonghobae Aug 12, 2026
1c0b254
fix(images): fail closed on malformed byte limits
seonghobae Aug 12, 2026
4979f5d
test(reliability): preflight inline image byte policy
seonghobae Aug 12, 2026
17f6806
fix(reliability): validate inline image policy before source scan
seonghobae Aug 12, 2026
151ae9e
test(privacy): expose custom image scheme in diagnostics
seonghobae Aug 13, 2026
14fad0e
fix(privacy): redact custom image schemes from diagnostics
seonghobae Aug 14, 2026
7a52faf
fix(privacy): preserve fixed image scheme categories
seonghobae Aug 14, 2026
c89a348
test(reliability): bound inline image diagnostic scan
seonghobae Aug 14, 2026
84d3ae0
fix(reliability): bound inline image diagnostic scan
seonghobae Aug 14, 2026
ad6fb67
test(reliability): preflight oversized inline source scans
seonghobae Aug 14, 2026
e0a9215
fix(reliability): preflight oversized inline source scans
seonghobae Aug 14, 2026
fa7c46e
test(reliability): cover bounded image preflight paths
seonghobae Aug 14, 2026
450847b
test(reliability): preserve malformed inline precedence
seonghobae Aug 14, 2026
7701b9d
fix(reliability): preserve strict image grammar precedence
seonghobae Aug 14, 2026
12ed1fc
test(data-integrity): reject non-canonical inline base64 padding bits
seonghobae Aug 15, 2026
c947e9f
fix(data-integrity): enforce canonical inline base64 padding bits
seonghobae Aug 15, 2026
46ace6d
fix(ci): reconcile release workflow with protected main
seonghobae Aug 16, 2026
f0a4104
fix(ci): align release asset inventory contract
seonghobae Aug 16, 2026
921c906
fix(ci): align release entry-type contract
seonghobae Aug 16, 2026
6f17bc3
docs(release): align SBOM provenance contract
seonghobae Aug 16, 2026
f0e1a2e
merge: synchronize inline image preflight with protected main
seonghobae Aug 17, 2026
4327208
chore(sync): integrate protected security baseline
seonghobae Aug 25, 2026
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
112 changes: 97 additions & 15 deletions src/policy/inlineImagePolicy.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,101 @@
import {
Base64SizeError,
dataUriByteLength,
} from '../converter/base64.js';
import { Base64SizeError } from '../converter/base64.js';

/** Strict raster-only data-URI form accepted by Inkspan document surfaces. */
const INLINE_RASTER_SOURCE_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/i;
/** Bounded raster data-URI prefix recognized before payload validation. */
const INLINE_RASTER_SOURCE_PREFIX_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,/i;

/** One canonical base64 payload code unit; padding is handled separately. */
const BASE64_PAYLOAD_CODE_UNIT_PATTERN = /^[A-Za-z0-9+/]$/;

/** Valid final sextets before `==`; their four unused low bits are zero. */
const BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AQgw]$/;

/** Valid final sextets before `=`; their two unused low bits are zero. */
const BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AEIMQUYcgkosw048]$/;

/** Fixed public categories that reveal no caller-defined scheme label. */
const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN =
/^(?:data|https?|blob|file|javascript)$/i;

/** Maximum untrusted prefix inspected while classifying source metadata. */
const IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS = 64;

/** Return a bounded, payload-free category for an untrusted image source. */
function redactImageSource(source: unknown): string {
if (typeof source !== 'string') return `<${typeof source}>`;
if (source.length === 0) return '<empty>';
if (source.startsWith('//')) return '//<redacted>';
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(source)?.[1];
if (scheme) return `${scheme.toLowerCase()}:<redacted>`;
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(
source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS),
)?.[1];
if (scheme) {
if (PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN.test(scheme)) {
return `${scheme.toLowerCase()}:<redacted>`;
}
return '<scheme-redacted>';
}
return '<unrecognized>';
}

/** Return decoded bytes for a source whose strict base64 shape is known. */
function inlineRasterByteLength(source: string, payloadOffset: number): number {
const payloadLength = source.length - payloadOffset;
const padding = source.endsWith('==') ? 2 : Number(source.endsWith('='));
return (payloadLength / 4) * 3 - padding;
}

/**
* Validate the strict raster/base64 grammar without decoding or whole-source regex work.
*
* The MIME/prefix regex sees only a bounded prefix. Payload code units are then
* inspected incrementally so malformed-source precedence remains authoritative
* even for oversized candidates. Canonical padding is inferred only from the
* final one or two code units; any earlier `=` is rejected by the payload scan,
* and unused bits in the final data sextet must be zero.
*/
function strictInlineRasterPayloadOffset(source: string): number | null {
const prefixMatch = INLINE_RASTER_SOURCE_PREFIX_PATTERN.exec(
source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS),
);
if (!prefixMatch) return null;

const payloadOffset = prefixMatch[0].length;
const payloadLength = source.length - payloadOffset;
if (payloadLength < 4 || payloadLength % 4 !== 0) return null;

const padding = source.endsWith('==') ? 2 : Number(source.endsWith('='));
const payloadDataEnd = source.length - padding;
for (let index = payloadOffset; index < payloadDataEnd; index += 1) {
if (!BASE64_PAYLOAD_CODE_UNIT_PATTERN.test(source.charAt(index))) {
return null;
}
}

const finalDataCodeUnit = source.charAt(payloadDataEnd - 1);
if (
padding === 2 &&
!BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit)
) {
return null;
}
if (
padding === 1 &&
!BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit)
) {
return null;
}
return payloadOffset;
}

/** Reject malformed public byte ceilings without coercion or intent inference. */
function assertValidInlineImageByteLimit(maxSizeBytes: number): void {
if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) {
throw new RangeError(
'inline image byte limit must be a non-negative safe integer',
);
}
}

/** Error thrown when an image source violates Inkspan's inline raster policy. */
export class Base64ImageSourceError extends Error {
/** Redacted source category safe for logs and host telemetry. */
Expand All @@ -43,15 +122,18 @@ export function validateInlineImageSource(
source: unknown,
maxSizeBytes: number,
): string {
if (
typeof source !== 'string' ||
source.length === 0 ||
!INLINE_RASTER_SOURCE_PATTERN.test(source)
) {
assertValidInlineImageByteLimit(maxSizeBytes);
if (typeof source !== 'string' || source.length === 0) {
throw new Base64ImageSourceError(source);
}

const payloadOffset = strictInlineRasterPayloadOffset(source);
if (payloadOffset === null) {
throw new Base64ImageSourceError(source);
}

if (maxSizeBytes > 0) {
const bytes = dataUriByteLength(source);
const bytes = inlineRasterByteLength(source, payloadOffset);
if (bytes > maxSizeBytes) {
throw new Base64SizeError(bytes, maxSizeBytes);
}
Expand Down
145 changes: 145 additions & 0 deletions src/policy/inlineImagePolicyPreflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest';

import { Base64SizeError } from '../converter/base64.js';
import {
Base64ImageSourceError,
validateInlineImageSource,
} from './inlineImagePolicy.js';

const OVERSIZED_IMAGE = 'data:image/png;base64,QUJDRA==';

describe('inline image decoded-size preflight', () => {
it('rejects an oversized valid image without decoding its base64 payload', () => {
const decodeSpy = vi.spyOn(globalThis.Buffer, 'from');

try {
expect(() => validateInlineImageSource(OVERSIZED_IMAGE, 3)).toThrowError(
expect.objectContaining({
name: 'Base64SizeError',
bytes: 4,
maxBytes: 3,
} satisfies Partial<Base64SizeError>),
);
expect(
decodeSpy.mock.calls.some((call) => {
const args = call as unknown as readonly unknown[];
return args[0] === 'QUJDRA==' && args[1] === 'base64';
}),
).toBe(false);
} finally {
decodeSpy.mockRestore();
}
});

it.each([Number.NaN, -1, 1.5, Number.POSITIVE_INFINITY])(
'rejects malformed public byte limit %s instead of weakening the resource policy',
(maxSizeBytes) => {
expect(() => validateInlineImageSource(OVERSIZED_IMAGE, maxSizeBytes)).toThrowError(
new RangeError('inline image byte limit must be a non-negative safe integer'),
);
},
);

it('rejects an unusable byte limit before scanning caller-controlled image source text', () => {
const source = 'data:image/png;base64,QUJDRA==';
const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test');

try {
expect(() => validateInlineImageSource(source, Number.NaN)).toThrowError(
new RangeError('inline image byte limit must be a non-negative safe integer'),
);
expect(
regexpTestSpy.mock.calls.some((call) => call[0] === source),
).toBe(false);
} finally {
regexpTestSpy.mockRestore();
}
});

it('rejects a provably oversized valid raster source before full-payload regex scanning', () => {
const source = `data:image/png;base64,${'QUJD'.repeat(16_384)}`;
const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test');

try {
expect(() => validateInlineImageSource(source, 3)).toThrowError(
expect.objectContaining({
name: 'Base64SizeError',
maxBytes: 3,
} satisfies Partial<Base64SizeError>),
);
expect(
regexpTestSpy.mock.calls.some((call) => call[0] === source),
).toBe(false);
} finally {
regexpTestSpy.mockRestore();
}
});

it('preserves malformed-source precedence even when the candidate is oversized', () => {
const source = `data:image/png;base64,${'QUJD'.repeat(16_383)}QU*D`;

expect(() => validateInlineImageSource(source, 3)).toThrow(
Base64ImageSourceError,
);
});

it.each([
'data:image/png;base64,AR==',
'data:image/png;base64,AQJ=',
])('rejects non-canonical base64 padding bits in %s', (source) => {
expect(() => validateInlineImageSource(source, 0)).toThrow(
Base64ImageSourceError,
);
});

it.each([
'https://example.invalid/image.png',
'data:image/png;base64,',
'data:image/png;base64,AAA',
'data:image/png;base64,AA*A',
])('defers in-bound malformed candidate %s to the strict source grammar', (source) => {
expect(() => validateInlineImageSource(source, 4)).toThrow(
Base64ImageSourceError,
);
});

it('accounts for a single canonical padding byte in an in-bound valid source', () => {
const source = 'data:image/png;base64,QUJDRAA=';

expect(validateInlineImageSource(source, 5)).toBe(source);
expect(() => validateInlineImageSource(source, 4)).toThrowError(
expect.objectContaining({
name: 'Base64SizeError',
bytes: 5,
maxBytes: 4,
} satisfies Partial<Base64SizeError>),
);
});

it('does not reflect a caller-controlled custom URI scheme in diagnostics', () => {
const privateMarker = 'privatetenant42';
const error = new Base64ImageSourceError(`${privateMarker}:opaque`);

expect(error.sourcePreview).toBe('<scheme-redacted>');
expect(error.message).not.toContain(privateMarker);
});

it('bounds diagnostic scheme inspection before regex work on untrusted source text', () => {
const source = 'a'.repeat(65_536);
const regexpExecSpy = vi.spyOn(RegExp.prototype, 'exec');
let inspectedLengths: number[] = [];

try {
const error = new Base64ImageSourceError(source);
inspectedLengths = regexpExecSpy.mock.calls
.map((call) => call[0])
.filter((value): value is string => typeof value === 'string')
.map((value) => value.length);
expect(error.sourcePreview).toBe('<unrecognized>');
} finally {
regexpExecSpy.mockRestore();
}

expect(Math.max(...inspectedLengths)).toBeLessThanOrEqual(64);
});
});
Loading