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
16 changes: 9 additions & 7 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { resolveClientVisitBatchId, synthesizeCacheHitEntry, synthesizeClientVisitEntry } from './background/synthesize'
import { ensureTabRule, migrateTabRule, removeTabRule, syncAllTabRules } from './background/tabRules'
import { browser } from './browser'
import { DEVTOOLS_ID_HEADER, REEMIT_PAGE_STATE_MESSAGE } from './constants'
import { DEVTOOLS_BASE_PATH_HEADER, DEVTOOLS_ID_HEADER, REEMIT_PAGE_STATE_MESSAGE } from './constants'
import { isBackgroundMessage } from './guards'
import type {
ClientVisitSnapshot,
Expand Down Expand Up @@ -64,8 +64,10 @@ browser.webRequest.onHeadersReceived.addListener(
return
}

const requestUrl = new URL(url)

// Opt-in dev knob: `?max_entries=N` on the inspected page caps that tab's buffer.
const maxEntries = Number(new URL(url).searchParams.get('max_entries'))
const maxEntries = Number(requestUrl.searchParams.get('max_entries'))

if (Number.isInteger(maxEntries) && maxEntries > 0) {
setTabMaxEntries(tabId, maxEntries)
Expand All @@ -77,10 +79,10 @@ browser.webRequest.onHeadersReceived.addListener(
return
}

const origin = new URL(url).origin
const basePathHeader = responseHeaders.find((header) => header.name.toLowerCase() === DEVTOOLS_BASE_PATH_HEADER)

void noteProvenHost(origin)
void ingestEntry(tabId, origin, idHeader.value)
void noteProvenHost(requestUrl.origin)
void ingestEntry(tabId, requestUrl.origin, idHeader.value, basePathHeader?.value)
},
{ urls: ['<all_urls>'] },
['responseHeaders'],
Expand All @@ -91,7 +93,7 @@ self.__inertiaDevtools = {
getOrigin: (tabId) => getOrigin(tabId),
getPageStates: (tabId) => getPageStatesForTab(tabId),
clearAll: () => clearAll(),
ingest: (tabId, origin, id) => ingestEntry(tabId, origin, id),
ingest: (tabId, origin, id, basePath) => ingestEntry(tabId, origin, id, basePath),
forgetHosts: async () => {
await forgetProvenHosts()
await syncAllTabRules()
Expand Down Expand Up @@ -179,7 +181,7 @@ function handleContentMessage(tabId: number, message: ContentToBackgroundMessage
switch (message.type) {
case 'content:initial-id':
void noteProvenHost(message.origin)
void ingestEntry(tabId, message.origin, message.id)
void ingestEntry(tabId, message.origin, message.id, message.basePath)
return

case 'content:cache-hit':
Expand Down
62 changes: 52 additions & 10 deletions src/background/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { discardPendingEntry, reservePendingEntry, setOrigin } from './runtimeSt

const MAX_ID_LENGTH = 256

// The origin and id both originate from a content:initial-id message, which is ultimately
// seeded from a page-controlled DOM tag. Validate before building a fetch URL: only same-scheme
// http(s) origins, and a bounded non-empty id (also percent-encoded below, so path traversal
// cannot escape the entries endpoint).
// Origin, id, and mount path all reach the worker from the inspected page, through a DOM tag or
// through the observed request URL and its headers. Validate before building a fetch URL: http(s)
// origins only, and a bounded non-empty id, percent-encoded below so traversal cannot escape.
function isSafeOrigin(origin: string): boolean {
try {
const url = new URL(origin)
Expand All @@ -24,15 +23,43 @@ function isSafeId(id: string): boolean {
}

/**
* Fetch a recorded entry from the inspected app after validating page-supplied location data.
* Build the entries URL under the mount path, or null when parsing rewrites it.
*
* String checks cannot see through encoded spellings: `/portal/%2e%2e` reads as an ordinary
* segment and still climbs out. So the parsed result is compared back, decoded, because a path
* the parser merely percent-encoded (a space, a non-ASCII name) is legitimate.
*/
export async function fetchEntry(origin: string, id: string): Promise<Entry | null> {
if (!isSafeOrigin(origin) || !isSafeId(id)) {
function entryEndpoint(origin: string, basePath: string, id: string): string | null {
const path = `${basePath}/_inertia/devtools/entries/${encodeURIComponent(id)}`

let url: URL

try {
url = new URL(`${origin}${path}`)
} catch {
return null
}

if (url.origin !== new URL(origin).origin) {
return null
}

try {
return decodeURIComponent(url.pathname) === `${basePath}/_inertia/devtools/entries/${id}` ? url.href : null
} catch {
return null
}
}

async function requestEntry(origin: string, basePath: string, id: string): Promise<Entry | null> {
const endpoint = entryEndpoint(origin, basePath, id)

if (endpoint === null) {
return null
}

try {
const response = await fetch(`${origin}/_inertia/devtools/entries/${encodeURIComponent(id)}`, {
const response = await fetch(endpoint, {
credentials: 'include',
cache: 'no-store',
})
Expand All @@ -51,13 +78,28 @@ export async function fetchEntry(origin: string, id: string): Promise<Entry | nu
}
}

/**
* Fetch a recorded entry from the inspected app after validating page-supplied location data.
*
* An app mounted under a subdirectory serves the endpoint under that same path, which only the
* recorder can report. A reported path that fails validation is never downgraded to the root:
* on a shared origin that would fetch an unrelated app's entry.
*/
export async function fetchEntry(origin: string, id: string, basePath = ''): Promise<Entry | null> {
if (!isSafeOrigin(origin) || !isSafeId(id)) {
return null
}

return requestEntry(origin, basePath, id)
}

/**
* Reserve a network entry before fetching it so early page-state snapshots can still pair.
*/
export async function ingestEntry(tabId: number, origin: string, id: string): Promise<void> {
export async function ingestEntry(tabId: number, origin: string, id: string, basePath = ''): Promise<void> {
reservePendingEntry(tabId, id)

const entry = await fetchEntry(origin, id)
const entry = await fetchEntry(origin, id, basePath)

if (!entry) {
discardPendingEntry(tabId, id)
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ export const UNPAIRED_PAGE_STATE_LIMIT = 16
export const DEVTOOLS_MESSAGE_SOURCE = 'inertia-devtools'
export const REEMIT_PAGE_STATE_MESSAGE = 'devtools:reemit-page-state'
export const DEVTOOLS_ID_HEADER = 'x-inertia-devtools-id'
export const DEVTOOLS_BASE_PATH_HEADER = 'x-inertia-devtools-base-path'
export const DEVTOOLS_TAB_HEADER = 'x-inertia-devtools-tab'
export const INITIAL_ID_TAG_SELECTOR = 'script[data-inertia-devtools-id][type="application/json"]'
export const INITIAL_BASE_PATH_ATTRIBUTE = 'data-inertia-devtools-base-path'
export const TAB_STORAGE_KEY_PREFIX = 'tab-'
export const DEVTOOLS_HOSTS_STORAGE_KEY = 'devtools-hosts'
export const SESSION_TAB_ID_KEY = 'devtoolsTabId'
26 changes: 19 additions & 7 deletions src/content-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { BackgroundMessage } from './types'
// through to chrome.runtime unchecked.
const DEVTOOLS_MESSAGE_SOURCE = 'inertia-devtools'
const INITIAL_ID_TAG_SELECTOR = 'script[data-inertia-devtools-id][type="application/json"]'
const INITIAL_BASE_PATH_ATTRIBUTE = 'data-inertia-devtools-base-path'
const REEMIT_PAGE_STATE_MESSAGE = 'devtools:reemit-page-state'

const browser: typeof chrome = (globalThis as { browser?: typeof chrome }).browser ?? globalThis.chrome
Expand Down Expand Up @@ -132,33 +133,44 @@ function jsonSafeObject(value: unknown): Record<string, unknown> | null {
}
}

function readInitialEntryId(): string | null {
function readInitialEntry(): { id: string; basePath?: string } | null {
const tag = document.querySelector<HTMLScriptElement>(INITIAL_ID_TAG_SELECTOR)

if (!tag || !tag.textContent) {
return null
}

try {
const parsed: unknown = JSON.parse(tag.textContent)
let parsed: unknown

return typeof parsed === 'string' && parsed.length > 0 ? parsed : null
try {
parsed = JSON.parse(tag.textContent)
} catch {
return null
}

if (typeof parsed !== 'string' || parsed.length === 0) {
return null
}

// The mount path is only present when the app is not served from the root of its origin,
// and it is validated in the service worker before it reaches a fetch URL.
const basePath = stringValue(tag.getAttribute(INITIAL_BASE_PATH_ATTRIBUTE))

return { id: parsed, ...(basePath === null ? {} : { basePath }) }
}

function sendInitialEntryId(): void {
const id = readInitialEntryId()
const initial = readInitialEntry()

if (!id) {
if (!initial) {
return
}

safeSendMessage({
type: 'content:initial-id',
id,
id: initial.id,
origin: location.origin,
...(initial.basePath === undefined ? {} : { basePath: initial.basePath }),
})
}

Expand Down
6 changes: 5 additions & 1 deletion src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ function isNullableString(value: unknown): value is string | null {
return value === null || isString(value)
}

function isOptionalString(value: unknown): value is string | undefined {
return value === undefined || isString(value)
}

function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
Expand Down Expand Up @@ -65,7 +69,7 @@ export function isBackgroundMessage(value: unknown): value is BackgroundMessage

switch (value.type) {
case 'content:initial-id':
return isString(value.id) && value.id.length > 0 && isString(value.origin)
return isString(value.id) && value.id.length > 0 && isString(value.origin) && isOptionalString(value.basePath)
case 'content:cache-hit':
return isCacheHitShape(value)
case 'content:page-state':
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export type ClientVisitSnapshot = {
}

export type ContentToBackgroundMessage =
| { type: 'content:initial-id'; id: string; origin: string }
| { type: 'content:initial-id'; id: string; origin: string; basePath?: string }
| {
type: 'content:cache-hit'
url: string
Expand Down Expand Up @@ -169,6 +169,6 @@ export type DevToolsTestHooks = {
getOrigin: (tabId: number) => string | null
getPageStates: (tabId: number) => Record<string, PageStateSnapshot>
clearAll: () => void
ingest: (tabId: number, origin: string, id: string) => Promise<void>
ingest: (tabId: number, origin: string, id: string, basePath?: string) => Promise<void>
forgetHosts: () => Promise<void>
}
2 changes: 1 addition & 1 deletion tests/e2e/app/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"inertiajs/inertia-laravel": "^3.2.1",
"inertiajs/inertia-laravel": "^3.3",
"laravel/framework": "^13.8"
},
"autoload": {
Expand Down
16 changes: 8 additions & 8 deletions tests/e2e/app/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions tests/e2e/app/subdirectory-server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

// Serves the same app from a subdirectory, the way a symlinked document root does. The built-in
// server has no mount point, so REQUEST_URI keeps the prefix while SCRIPT_NAME points inside it,
// which is what Symfony derives Request::getBaseUrl() from. A request missing the prefix 404s.

$mount = '/mounted';

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';

if ($path !== $mount && ! str_starts_with($path, $mount.'/')) {
http_response_code(404);

return true;
}

// Never fall through to the built-in static handler: its document root is the app directory,
// not `public`, so a fall-through would serve `.env`.
$_SERVER['SCRIPT_NAME'] = $mount.'/index.php';
$_SERVER['PHP_SELF'] = $mount.'/index.php';
$_SERVER['SCRIPT_FILENAME'] = __DIR__.'/public/index.php';

require __DIR__.'/public/index.php';
15 changes: 15 additions & 0 deletions tests/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const runsInCI = !!process.env.CI
const url = 'http://127.0.0.1:13337'
const appDir = new URL('./app', import.meta.url).pathname

// The mount path is spelled in `subdirectory-server.php` too, which the built-in server reads
// as its own router and cannot import from here.
const subdirectoryPort = 13338
export const subdirectoryUrl = `http://127.0.0.1:${subdirectoryPort}/mounted`

export default defineConfig({
testDir: '.',
testMatch: /.*\.spec\.ts$/,
Expand Down Expand Up @@ -50,5 +55,15 @@ export default defineConfig({
reuseExistingServer: true,
timeout: 120 * 1000,
},
{
// The same app again, mounted under a subdirectory, for the specs that cover an install
// that is not served from the root of its origin. `setup.sh` belongs to the server above:
// running it here too would race that one over composer and the app key.
command: `PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:${subdirectoryPort} subdirectory-server.php`,
cwd: appDir,
url: `${subdirectoryUrl}/devtools`,
reuseExistingServer: true,
timeout: 120 * 1000,
},
],
})
Loading
Loading