Skip to content
Open
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 packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@linkforty/og-core",
"version": "0.1.0",
"version": "0.2.0",
"description": "Runtime-agnostic engine that fetches a URL as each social platform and reports what every one of them sees.",
"license": "MIT",
"type": "module",
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ export interface FetchResult {
chain: Hop[];
finalUrl: string;
finalStatus: number;
/** Response body, possibly truncated by the adapter. */
/** Response body decoded as text, possibly truncated by the adapter. */
body: string;
/**
* The same body before decoding.
*
* `body` is UTF-8 decoded, which is lossy for anything that is not text — an
* image fetched through this interface cannot be recovered from it. Adapters
* therefore expose the raw bytes too, so a caller that wants an image (to
* composite into a generated card, say) can use the same guarded fetcher as
* everything else rather than standing up a second, unguarded one.
*
* Optional so a custom adapter that only ever handles text stays valid.
*/
bodyBytes?: Uint8Array;
}

export interface FetchOptions {
Expand Down
2 changes: 1 addition & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@linkforty/og-node",
"version": "0.1.0",
"version": "0.2.0",
"description": "Node fetcher for @linkforty/og-core, hardened against SSRF — safe to point at untrusted URLs.",
"license": "MIT",
"type": "module",
Expand Down
37 changes: 29 additions & 8 deletions packages/node/src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,20 @@ function assertProtocolAllowed(url: URL): void {
}
}

/** Reads a response body, stopping once `maxBytes` have been consumed. */
async function readCapped(response: Response, maxBytes: number): Promise<string> {
/**
* Reads a response body as bytes, stopping once `maxBytes` have been consumed.
*
* Bytes rather than text: decoding here would be lossy for anything that is not
* UTF-8, and a caller fetching an image through this same guarded path could
* never recover the original from a decoded string. Text callers decode from
* this, which costs one pass and keeps one code path.
*/
async function readCappedBytes(response: Response, maxBytes: number): Promise<Uint8Array> {
const body = response.body;
if (!body) return '';
if (!body) return new Uint8Array(0);

const reader = body.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
const chunks: Uint8Array[] = [];
let total = 0;

try {
Expand All @@ -79,14 +85,21 @@ async function readCapped(response: Response, maxBytes: number): Promise<string>
if (!value) continue;

total += value.byteLength;
chunks.push(decoder.decode(value, { stream: true }));
chunks.push(value);
}
} finally {
// Stop the transfer rather than draining a hostile or enormous response.
await reader.cancel().catch(() => {});
}

return chunks.join('').slice(0, maxBytes);
const combined = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}

return combined.subarray(0, maxBytes);
}

export function createNodeFetcher(options: NodeFetcherOptions = {}): Fetcher {
Expand Down Expand Up @@ -133,7 +146,7 @@ export function createNodeFetcher(options: NodeFetcherOptions = {}): Fetcher {
chain,
finalUrl: current.toString(),
finalStatus: response.status,
body: await readCapped(response, maxBytes),
...toBody(await readCappedBytes(response, maxBytes)),
};
}

Expand All @@ -151,3 +164,11 @@ export function createNodeFetcher(options: NodeFetcherOptions = {}): Fetcher {
throw new FetchRejectedError(`More than ${maxHops} redirects`);
};
}

/**
* Presents one read as both shapes: decoded text for the Open Graph parser,
* and the original bytes for callers that need the file itself.
*/
function toBody(bytes: Uint8Array): { body: string; bodyBytes: Uint8Array } {
return { body: new TextDecoder().decode(bytes), bodyBytes: bytes };
}
37 changes: 37 additions & 0 deletions packages/node/test/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,40 @@ describe('createNodeFetcher', () => {
expect(init.redirect).toBe('manual');
});
});

describe('binary bodies', () => {
it('returns the original bytes alongside the decoded text', async () => {
// A PNG's first byte is 0x89, which is not valid UTF-8 on its own — it
// decodes to U+FFFD and is unrecoverable from the string. Fetching an image
// through this fetcher has to be possible, or a caller that needs one is
// pushed into writing a second, unguarded fetcher.
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
stubDns({ 'img.test': ['93.184.216.34'] });
fetchSpy.mockResolvedValue(new Response(png, { status: 200 }));

const result = await createNodeFetcher()('https://img.test/a.png', OPTIONS);

expect(result.bodyBytes).toBeInstanceOf(Uint8Array);
expect(Array.from(result.bodyBytes as Uint8Array)).toEqual(Array.from(png));
// The decoded string has already lost the data, which is the whole point.
expect(new TextEncoder().encode(result.body).length).not.toBe(png.length);
});

it('still decodes text bodies correctly', async () => {
stubDns({ 'example.com': ['93.184.216.34'] });
fetchSpy.mockResolvedValue(htmlResponse('<title>Héllo wörld</title>'));

const result = await createNodeFetcher()('https://example.com/', OPTIONS);

expect(result.body).toContain('Héllo wörld');
});

it('caps bytes as well as text', async () => {
stubDns({ 'big.test': ['93.184.216.34'] });
fetchSpy.mockResolvedValue(htmlResponse('x'.repeat(5000)));

const result = await createNodeFetcher({ maxBytes: 1000 })('https://big.test/', OPTIONS);

expect((result.bodyBytes as Uint8Array).length).toBeLessThanOrEqual(1000);
});
});
Loading