fix: authorize same-origin conversation reads without Origin

This commit is contained in:
2026-10-31 19:33:58 +09:00
parent e8477882b5
commit f7ea2362dd
7 changed files with 161 additions and 8 deletions
+12 -2
View File
@@ -28,6 +28,16 @@ async fn account_context(
api: &WorkspaceApi, api: &WorkspaceApi,
context: &ServerRequestContext, context: &ServerRequestContext,
workspace: &str, workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> {
let (account, authority) = read_context(api, context, workspace).await?;
authority.authorize_workspace(api).await?;
Ok((account, authority))
}
async fn read_context(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> { ) -> ApiResult<(String, WorkerOperationContext)> {
if context.runtime_source.is_some() || context.worker_source.is_some() { if context.runtime_source.is_some() || context.worker_source.is_some() {
return Err(Error::WorkspacePermissionDenied( return Err(Error::WorkspacePermissionDenied(
@@ -42,7 +52,7 @@ async fn account_context(
let authority = WorkerOperationContext::from_request_async(api, context) let authority = WorkerOperationContext::from_request_async(api, context)
.await .await
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?; .map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
authority.authorize_workspace(api).await?; authority.authorize_workspace_read(api).await?;
Ok((actor.account_id.clone(), authority)) Ok((actor.account_id.clone(), authority))
} }
@@ -70,7 +80,7 @@ pub(super) async fn messages(
conversation: String, conversation: String,
query: ChatMessagePageQuery, query: ChatMessagePageQuery,
) -> ApiResult<ChatMessagePage> { ) -> ApiResult<ChatMessagePage> {
let (account, _) = account_context(&api, &context, &workspace).await?; let (account, _) = read_context(&api, &context, &workspace).await?;
api.store api.store
.db_executor() .db_executor()
.run(move || -> ApiResult<_> { .run(move || -> ApiResult<_> {
@@ -141,6 +141,16 @@ async fn account_context(
api: &WorkspaceApi, api: &WorkspaceApi,
context: &server_api::ServerRequestContext, context: &server_api::ServerRequestContext,
workspace: &str, workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> {
let (account, authority) = read_context(api, context, workspace).await?;
authority.authorize_workspace(api).await?;
Ok((account, authority))
}
async fn read_context(
api: &WorkspaceApi,
context: &server_api::ServerRequestContext,
workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> { ) -> ApiResult<(String, WorkerOperationContext)> {
validate_workspace_scope(api, workspace)?; validate_workspace_scope(api, workspace)?;
let actor = subject_authorization::administration_actor(context)?; let actor = subject_authorization::administration_actor(context)?;
@@ -148,7 +158,7 @@ async fn account_context(
let authority = WorkerOperationContext::from_request_async(api, context) let authority = WorkerOperationContext::from_request_async(api, context)
.await .await
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?; .map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
authority.authorize_workspace(api).await?; authority.authorize_workspace_read(api).await?;
Ok((actor.account_id.clone(), authority)) Ok((actor.account_id.clone(), authority))
} }
@@ -158,7 +168,7 @@ pub(super) async fn get(
workspace: String, workspace: String,
subject: String, subject: String,
) -> ApiResult<SubjectBodyResponse> { ) -> ApiResult<SubjectBodyResponse> {
account_context(&api, &context, &workspace).await?; read_context(&api, &context, &workspace).await?;
api.host_executors api.host_executors
.host()? .host()?
.run(move || { .run(move || {
@@ -86,6 +86,113 @@ impl BodyFixture {
} }
} }
#[tokio::test]
async fn browser_subject_and_conversation_reads_allow_missing_origin_but_mutations_do_not() {
let f = BodyFixture::new().await;
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", f.owner).parse().unwrap(),
);
let actor = resolve_actor(&ServerAuthApi::from(&f.api), &headers)
.await
.unwrap()
.unwrap();
let token = "subject-read-browser-session";
f.api
.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash(token),
session_id: "subject-read-session".into(),
user_id: actor.user_id,
created_at: now_registry_timestamp(),
expires_at: "2099-01-01T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
let AuthConfig::Passkey {
cookie_name,
origin,
..
} = &f.api.config.auth;
let browser_request = |method: &str, path: &str, body: Option<Value>, origin: Option<&str>| {
let mut request = Request::builder()
.method(method)
.uri(path)
.header(axum::http::header::COOKIE, format!("{cookie_name}={token}"))
.header(CONTENT_TYPE, "application/json");
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
request
.body(Body::from(body.map(|v| v.to_string()).unwrap_or_default()))
.unwrap()
};
let conversations = format!("/api/w/{TEST_WORKSPACE_ID}/chat/conversations");
let create = json!({"operation_id":"browser-read-conversation", "subject_id":f.subject});
let (status, conversation) = response(
&f.api,
browser_request("POST", &conversations, Some(create.clone()), Some(origin)),
)
.await;
assert_eq!(status, StatusCode::OK, "{conversation}");
let messages = format!(
"{conversations}/{}/messages",
conversation["id"].as_str().unwrap()
);
for path in [f.path(), messages.clone()] {
for origin in [None, Some(origin.as_str())] {
let (status, body) =
response(&f.api, browser_request("GET", &path, None, origin)).await;
assert_eq!(status, StatusCode::OK, "{path}: {body}");
}
let (status, body) = response(
&f.api,
browser_request("GET", &path, None, Some("https://foreign.example")),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
}
let expected =
json!({"runtime_id":"runtime-test", "worker_id":"not-started", "generation":"1"});
for (path, body) in [
(conversations, create),
(
messages.clone(),
json!({"operation_id":"must-not-send", "text":"must not be saved"}),
),
(
format!("{}/start", f.path()),
json!({"request_id":"must-not-start", "expected_current":null, "expected_observation_token":null}),
),
(
format!("{}/stop", f.path()),
json!({"request_id":"must-not-stop", "expected_current":expected, "expected_observation_token":"not-observed"}),
),
] {
for origin in [None, Some("https://foreign.example")] {
let (status, error) = response(
&f.api,
browser_request("POST", &path, Some(body.clone()), origin),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: {error}");
}
}
let (status, page) = response(&f.api, browser_request("GET", &messages, None, None)).await;
assert_eq!(status, StatusCode::OK, "{page}");
assert_eq!(page["messages"], json!([]));
assert!(f.read().await["current"].is_null());
f.api
.store
.revoke_browser_session(&crate::auth::token_hash(token), &now_registry_timestamp())
.unwrap();
for path in [f.path(), messages] {
let (status, body) = response(&f.api, browser_request("GET", &path, None, None)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}");
}
}
#[tokio::test] #[tokio::test]
async fn body_read_retains_an_occupied_owner_when_runtime_observation_is_unavailable() { async fn body_read_retains_an_occupied_owner_when_runtime_observation_is_unavailable() {
let f = BodyFixture::new().await; let f = BodyFixture::new().await;
@@ -121,6 +121,20 @@ impl WorkerOperationContext {
} }
pub(super) async fn authorize_workspace(&self, api: &WorkspaceApi) -> ApiResult<()> { pub(super) async fn authorize_workspace(&self, api: &WorkspaceApi) -> ApiResult<()> {
self.authorize_workspace_request(api, true).await
}
/// Read-only snapshots revalidate the caller without requiring the Origin header
/// that browsers omit on same-origin GETs. An explicit foreign Origin still fails.
pub(super) async fn authorize_workspace_read(&self, api: &WorkspaceApi) -> ApiResult<()> {
self.authorize_workspace_request(api, false).await
}
async fn authorize_workspace_request(
&self,
api: &WorkspaceApi,
require_origin: bool,
) -> ApiResult<()> {
if let Self::Browser { headers, source } = self { if let Self::Browser { headers, source } = self {
let actor = resolve_actor(&ServerAuthApi::from(api), headers) let actor = resolve_actor(&ServerAuthApi::from(api), headers)
.await? .await?
@@ -129,7 +143,9 @@ impl WorkerOperationContext {
"Worker connection credentials were revoked".into(), "Worker connection credentials were revoked".into(),
) )
})?; })?;
if matches!(actor.auth_method, ActorAuthMethod::BrowserSession) { if matches!(actor.auth_method, ActorAuthMethod::BrowserSession)
&& (require_origin || headers.contains_key(ORIGIN))
{
let AuthConfig::Passkey { origin, .. } = &api.config.auth; let AuthConfig::Passkey { origin, .. } = &api.config.auth;
if headers.get(ORIGIN).and_then(|value| value.to_str().ok()) if headers.get(ORIGIN).and_then(|value| value.to_str().ok())
!= Some(origin.as_str()) != Some(origin.as_str())
@@ -74,7 +74,6 @@ try {
const context = await browser.newContext({ const context = await browser.newContext({
viewport: { width: 1440, height: 1000 }, viewport: { width: 1440, height: 1000 },
colorScheme: "light", colorScheme: "light",
extraHTTPHeaders: { Origin: origin.origin },
}); });
const sessionCookie = (value: string) => ({ const sessionCookie = (value: string) => ({
name: "yoi_workspace_session", name: "yoi_workspace_session",
@@ -212,7 +211,10 @@ try {
const retry = posts[2]; const retry = posts[2];
const conflict = await context.request.post( const conflict = await context.request.post(
`${origin.origin}${base}/${encodeURIComponent(input.conversation_id)}/messages`, `${origin.origin}${base}/${encodeURIComponent(input.conversation_id)}/messages`,
{ data: { ...retry, text: "changed payload under original operation" } }, {
headers: { Origin: origin.origin },
data: { ...retry, text: "changed payload under original operation" },
},
); );
assertEquals(conflict.status(), 409); assertEquals(conflict.status(), 409);
await page.goto(`${origin.origin}${legacy}/conversation${suffix}`); await page.goto(`${origin.origin}${legacy}/conversation${suffix}`);
@@ -365,6 +367,7 @@ try {
.json(); .json();
assert(ownerIdentity.actor.account_id !== foreignIdentity.actor.account_id); assert(ownerIdentity.actor.account_id !== foreignIdentity.actor.account_id);
const denied = await context.request.post(deniedPath, { const denied = await context.request.post(deniedPath, {
headers: { Origin: origin.origin },
data: { operation_id: "foreign-cannot-post", text: "must not append" }, data: { operation_id: "foreign-cannot-post", text: "must not append" },
}); });
assertEquals(denied.status(), 403); assertEquals(denied.status(), 403);
@@ -74,6 +74,8 @@
window.addEventListener('online', reconnect); window.addEventListener('online', reconnect);
document.addEventListener('visibilitychange', reconnect); document.addEventListener('visibilitychange', reconnect);
const unsubscribe = browserSession.subscribe(() => { const unsubscribe = browserSession.subscribe(() => {
// A withdrawn request keeps its actual failure; an aborted fetch is not an Account change.
if (active !== context || context.controller.signal.aborted) return;
if (!current(context)) { if (!current(context)) {
withdrawAccess(context); withdrawAccess(context);
sendError = null; sendError = null;
@@ -62,6 +62,9 @@ for (const status of [401, 403, 404]) {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: string, init?: RequestInit) => { vi.fn(async (input: string, init?: RequestInit) => {
if (String(input) === "/api/auth/whoami" && browserSession.snapshot.kind === "ready") {
return Response.json(browserSession.snapshot.value);
}
if (String(input).endsWith("/conversations")) return Response.json(conversation("s")); if (String(input).endsWith("/conversations")) return Response.json(conversation("s"));
if (init?.method === "POST") { if (init?.method === "POST") {
posted = true; posted = true;
@@ -84,7 +87,9 @@ for (const status of [401, 403, 404]) {
const attempt = readConversationAttempt(sessionStorage, props)!; const attempt = readConversationAttempt(sessionStorage, props)!;
denied = true; denied = true;
await fireEvent.click(screen.getByRole("button", { name: "Refresh history" })); await fireEvent.click(screen.getByRole("button", { name: "Refresh history" }));
await screen.findByRole("alert"); const deniedMessage = (await screen.findByRole("alert")).textContent;
await browserSession.refresh();
expect(screen.getByRole("alert").textContent).toBe(deniedMessage);
expect(screen.queryByText("retained private text")).toBeNull(); expect(screen.queryByText("retained private text")).toBeNull();
expect(screen.queryByRole("textbox")).toBeNull(); expect(screen.queryByRole("textbox")).toBeNull();
release(Response.json({ release(Response.json({