1
0
forked from baron/baron-sso

모바일 fallback 변경. .env유출 가능성 차단

This commit is contained in:
2026-05-26 11:30:00 +09:00
parent 0eb6dabdc1
commit e481ae2821
18 changed files with 177 additions and 52 deletions

View File

@@ -14,7 +14,7 @@ func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
}
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso.example.test/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
req := buildNaverSmsRequest("0262857755", "821012345678", content)
if req.Type != "LMS" {

View File

@@ -159,12 +159,12 @@ export async function seedAuth(page: Page, role?: string) {
const storageKeys = [
"user:http://localhost:5000/oidc:devfront",
"user:http://localhost:5000/oidc/:devfront",
"user:https://sso-test.hmac.kr/oidc:devfront",
"user:https://sso-test.hmac.kr/oidc/:devfront",
"user:https://sso.example.test/oidc:devfront",
"user:https://sso.example.test/oidc/:devfront",
"oidc.user:http://localhost:5000/oidc:devfront",
"oidc.user:http://localhost:5000/oidc/:devfront",
"oidc.user:https://sso-test.hmac.kr/oidc:devfront",
"oidc.user:https://sso-test.hmac.kr/oidc/:devfront",
"oidc.user:https://sso.example.test/oidc:devfront",
"oidc.user:https://sso.example.test/oidc/:devfront",
];
for (const key of storageKeys) {

View File

@@ -91,11 +91,9 @@ services:
backend:
condition: service_healthy
command: >
/bin/sh -c "mkdir -p /usr/share/nginx/html/assets &&
echo \"BACKEND_URL=${BACKEND_URL:-}\" >> /usr/share/nginx/html/assets/.env &&
echo \"USERFRONT_URL=${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env &&
echo \"APP_ENV=stage\" >> /usr/share/nginx/html/assets/.env &&
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&
/bin/sh -c "echo \"[userfront-runtime] BACKEND_URL configured: $${BACKEND_URL:+yes}\" &&
echo \"[userfront-runtime] USERFRONT_URL configured: $${USERFRONT_URL:+yes}\" &&
echo \"[userfront-runtime] APP_ENV=$${APP_ENV:-stage}\" &&
nginx -g 'daemon off;'"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]

View File

@@ -520,11 +520,9 @@ services:
backend:
condition: service_healthy
command: >
/bin/sh -c "mkdir -p /usr/share/nginx/html/assets &&
echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env &&
echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env &&
echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env &&
cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env &&
/bin/sh -c "echo \"[userfront-runtime] BACKEND_URL configured: $${BACKEND_URL:+yes}\" &&
echo \"[userfront-runtime] USERFRONT_URL configured: $${USERFRONT_URL:+yes}\" &&
echo \"[userfront-runtime] APP_ENV=$${APP_ENV}\" &&
nginx -g 'daemon off;'"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]

View File

@@ -82,7 +82,7 @@ hydra clients create
### 발생 원인 (Problem)
* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다.
* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다.
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다.
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso.example.test` 도메인의 쿠키가 포함되지 않았습니다.
* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다.
### 해결 방법 (Solution)

View File

@@ -17,7 +17,7 @@
3. 매핑 체인이 없거나 규칙이 누락된 경우는 실패(`unmapped_fail`)로 간주합니다.
## 용어 정의
- Public URL: 브라우저에서 접근하는 URL. 예: `https://sso-test.hmac.kr/oidc/oauth2/auth`
- Public URL: 브라우저에서 접근하는 URL. 예: `https://sso.example.test/oidc/oauth2/auth`
- Internal URL: 컨테이너 내부 통신 URL. 예: `http://hydra:4444/oauth2/auth`
- Mapping Chain: Public 요청이 Gateway/Oathkeeper 규칙을 통해 Internal URL로 전달되는 경로

View File

@@ -40,10 +40,14 @@ grep -Fq -- "AS dev" "$USERFRONT_DOCKERFILE" || fail "userfront Dockerfile must
grep -Fq -- "AS production" "$USERFRONT_DOCKERFILE" || fail "userfront Dockerfile must keep an explicit production target"
grep -Fq -- "flutter run" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must use flutter run"
grep -Fq -- "--wasm" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must keep WebAssembly enabled"
grep -Fq -- "--dart-define=BACKEND_URL=" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must pass backend URL through dart-define"
grep -Fq -- "--dart-define=CLIENT_LOG_DEBUG=" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must pass client log debug mode through dart-define"
grep -Fq -- "--dart-define=APP_ENV=" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must pass app env through dart-define"
grep -Fq -- "--dart-define=USERFRONT_URL=" "$USERFRONT_DEV_SERVER" || fail "userfront dev server must pass userfront URL through dart-define"
grep -Fq -- 'USERFRONT_FLUTTER_RUN_FLAGS' "$USERFRONT_DEV_SERVER" || fail "userfront dev server must accept optional Flutter run flags"
assert_contains 'CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false}'
assert_contains 'BACKEND_URL=${BACKEND_URL:-}'
assert_contains 'USERFRONT_URL=${USERFRONT_URL}'
assert_contains 'USERFRONT_FLUTTER_RUN_FLAGS=${USERFRONT_FLUTTER_RUN_FLAGS:-}'
if grep -Fq -- "--debug" "$USERFRONT_DEV_SERVER"; then
fail "make dev must not hard-code Flutter debug mode in the userfront dev server"

View File

@@ -46,6 +46,13 @@ if rg -n "gzip|gzipSync|\\.gz" userfront/nginx.conf userfront/scripts/optimize-w
fail "userfront web compression must be managed as brotli-only"
fi
rg -q "Cache-Control.*no-cache" userfront/nginx.conf || fail "HTML/app shell must use no-cache revalidation"
if rg -n "assets/\\.env|/\\.env|runtimeEnvBody|dotenv\\.load" userfront/lib userfront/nginx.conf userfront-e2e/scripts/serve-userfront-build.mjs; then
fail "userfront must not request, load, or serve public .env assets to browsers"
fi
if rg -n "/usr/share/nginx/html/.+\\.env|assets/\\.env|cp .+\\.env" docker/docker-compose.staging.template.yaml docker/staging_pull_compose.template.yaml; then
fail "userfront deployment must not write runtime .env into the public static document root"
fi
rg -q "\\[userfront-runtime\\] BACKEND_URL configured" docker/docker-compose.staging.template.yaml docker/staging_pull_compose.template.yaml || fail "userfront runtime config presence must be logged server-side only"
rg -q "Cache-Control.*immutable" userfront/nginx.conf || fail "versioned static assets must use immutable cache"
tmp_dir="$(mktemp -d)"

View File

@@ -0,0 +1,97 @@
import { expect, test, type Page } from '@playwright/test';
type SigninCase = {
path: '/ko/signin' | '/en/signin';
theme: 'light' | 'dark';
};
const signinCases: SigninCase[] = [
{ path: '/ko/signin', theme: 'light' },
{ path: '/ko/signin', theme: 'dark' },
{ path: '/en/signin', theme: 'light' },
{ path: '/en/signin', theme: 'dark' },
];
async function mockPublicApis(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith('/api/v1/user/me')) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
});
return;
}
if (requestUrl.pathname.endsWith('/api/v1/auth/tenant-info')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
}
async function expectFlutterCanvasRendered(page: Page): Promise<void> {
const canvas = page.locator('canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
const box = await canvas.boundingBox();
expect(box?.width ?? 0).toBeGreaterThan(100);
expect(box?.height ?? 0).toBeGreaterThan(100);
}
async function seedAuthTheme(page: Page, theme: SigninCase['theme']): Promise<void> {
await page.addInitScript((themeValue) => {
window.localStorage.setItem('userfront_auth_theme', themeValue);
window.localStorage.setItem('flutter.userfront_auth_theme', themeValue);
}, theme);
}
test.describe('UserFront signin runtime matrix', () => {
test.beforeEach(async ({ page }) => {
await mockPublicApis(page);
});
for (const entry of signinCases) {
test(`${entry.path} renders in ${entry.theme} theme`, async ({ page }) => {
await seedAuthTheme(page, entry.theme);
await page.goto(entry.path);
await expect(page).toHaveURL(new RegExp(`${entry.path}(?:\\?.*)?$`));
await expectFlutterCanvasRendered(page);
});
}
test('signin uses configured BACKEND_URL for public API requests', async ({
page,
}) => {
const expectedBackendOrigin = process.env.EXPECTED_BACKEND_ORIGIN;
test.skip(!expectedBackendOrigin, 'set EXPECTED_BACKEND_ORIGIN');
const requestedApiOrigins = new Set<string>();
page.on('request', (request) => {
const requestUrl = new URL(request.url());
if (requestUrl.pathname.startsWith('/api/v1/')) {
requestedApiOrigins.add(requestUrl.origin);
}
});
for (const entry of signinCases) {
await seedAuthTheme(page, entry.theme);
await page.goto(entry.path);
await expectFlutterCanvasRendered(page);
await expect
.poll(() => [...requestedApiOrigins], { timeout: 30_000 })
.toContain(expectedBackendOrigin);
expect(requestedApiOrigins).not.toContain('https://sso.example.test');
}
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso-test.hmac.kr';
const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso.example.test';
const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? 'http://localhost:5173';
const LOGIN_ID = process.env.E2E_LOGIN_ID ?? '';
const PASSWORD = process.env.E2E_PASSWORD ?? '';
@@ -134,7 +134,7 @@ async function loginAdminFront(context: BrowserContext): Promise<Page> {
if (/\/login$/.test(page.url())) {
const authorizeUrl = await page.evaluate(() => {
const origin = window.location.origin;
const authority = 'https://sso-test.hmac.kr/oidc';
const authority = `${USERFRONT_BASE_URL}/oidc`;
const params = new URLSearchParams({
client_id: 'adminfront',
redirect_uri: `${origin}/auth/callback`,

View File

@@ -1,9 +1,9 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
const _compileTimeEnv = {
'APP_ENV': String.fromEnvironment('APP_ENV'),
'BACKEND_URL': String.fromEnvironment('BACKEND_URL'),
'CLIENT_LOG_DEBUG': String.fromEnvironment('CLIENT_LOG_DEBUG'),
'USERFRONT_DEBUG_LOG': String.fromEnvironment('USERFRONT_DEBUG_LOG'),
'USERFRONT_URL': String.fromEnvironment('USERFRONT_URL'),
};
String runtimeOriginFallback() {
@@ -13,17 +13,10 @@ String runtimeOriginFallback() {
return origin;
}
} catch (_) {}
return 'https://sso-test.hmac.kr';
return '';
}
String envOrDefault(String key, String fallback) {
if (dotenv.isInitialized) {
final value = dotenv.env[key];
if (value != null && value.trim().isNotEmpty) {
return value;
}
}
final compileTimeValue = _compileTimeEnv[key];
if (compileTimeValue != null && compileTimeValue.trim().isNotEmpty) {
return compileTimeValue;

View File

@@ -150,14 +150,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: d4130c4a43e0b13fefc593bc3961f2cb46e30cb79e253d4a526b1b5d24ae1ce4
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_driver:
dependency: transitive
description: flutter
@@ -276,14 +268,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:

View File

@@ -39,7 +39,6 @@ dependencies:
flutter_riverpod: ^3.0.3
go_router: ^17.0.1
http: ^1.6.0
flutter_dotenv: ^6.0.0
flutter_svg: ^2.2.1
url_launcher: ^6.3.2
logging: ^1.2.0

View File

@@ -10,8 +10,10 @@ set -- flutter run \
--web-hostname 0.0.0.0 \
--web-port "${USERFRONT_INTERNAL_PORT:-5000}" \
--wasm \
--dart-define=BACKEND_URL="${BACKEND_URL:-}" \
--dart-define=CLIENT_LOG_DEBUG="${CLIENT_LOG_DEBUG:-false}" \
--dart-define=APP_ENV="${APP_ENV:-dev}" \
--dart-define=USERFRONT_URL="${USERFRONT_URL:-}" \
${USERFRONT_FLUTTER_RUN_FLAGS:-} \
--no-web-resources-cdn

View File

@@ -8,7 +8,7 @@ void main() {
widgetLoginChallenge: 'widget-challenge',
uri: Uri.parse('/ko/login'),
rawSearch: '?login_challenge=raw-search',
rawHref: 'https://sso-test.hmac.kr/ko/login?login_challenge=raw-href',
rawHref: 'https://sso.example.test/ko/login?login_challenge=raw-href',
);
expect(resolved.value, 'widget-challenge');
@@ -46,7 +46,7 @@ void main() {
uri: Uri.parse('/ko/login'),
rawSearch: '',
rawHref:
'https://sso-test.hmac.kr/ko/login?a=1&login_challenge=raw-href-value#fragment',
'https://sso.example.test/ko/login?a=1&login_challenge=raw-href-value#fragment',
);
expect(resolved.value, 'raw-href-value');
@@ -59,7 +59,7 @@ void main() {
widgetLoginChallenge: null,
uri: Uri.parse('/ko/login'),
rawSearch: '',
rawHref: 'https://sso-test.hmac.kr/ko/login?x=1',
rawHref: 'https://sso.example.test/ko/login?x=1',
);
expect(resolved.value, isNull);

View File

@@ -5,12 +5,12 @@ void main() {
group('oidc_redirect_guard', () {
test('http/https 절대 URL만 허용', () {
final ok = validateOidcRedirectTarget(
'https://sso-test.hmac.kr/oidc/oauth2/auth?client_id=devfront&login_verifier=abc&state=xyz&code_challenge=ccc&code_challenge_method=S256&response_type=code&scope=openid%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A5174%2Fcallback',
'https://sso.example.test/oidc/oauth2/auth?client_id=devfront&login_verifier=abc&state=xyz&code_challenge=ccc&code_challenge_method=S256&response_type=code&scope=openid%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A5174%2Fcallback',
);
expect(ok.isValid, isTrue);
expect(ok.reason, 'ok');
expect(ok.scheme, 'https');
expect(ok.host, 'sso-test.hmac.kr');
expect(ok.host, 'sso.example.test');
expect(ok.path, '/oidc/oauth2/auth');
expect(ok.isOidcAuthPath, isTrue);
expect(ok.queryParamCount, 8);

View File

@@ -7,7 +7,7 @@ void main() {
final action = decidePasswordLoginNextAction(
hasLoginChallenge: true,
redirectTo:
'https://sso-test.hmac.kr/oidc/oauth2/auth?login_verifier=a',
'https://sso.example.test/oidc/oauth2/auth?login_verifier=a',
jwt: 'jwt-token',
);

View File

@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/services/runtime_env.dart';
const _expectedBackendUrl = String.fromEnvironment('BACKEND_URL');
const _expectedUserfrontUrl = String.fromEnvironment('USERFRONT_URL');
void main() {
group('runtime env compile-time defines', () {
test('runtime fallback is empty outside a browser origin', () {
expect(runtimeOriginFallback(), isEmpty);
});
test('BACKEND_URL dart-define overrides runtime origin fallback when set', () {
if (_expectedBackendUrl.isEmpty) {
expect(runtimeBackendUrl(), runtimeOriginFallback());
return;
}
expect(runtimeBackendUrl(), sanitizedUrl(_expectedBackendUrl));
});
test(
'USERFRONT_URL dart-define overrides runtime origin fallback when set',
() {
if (_expectedUserfrontUrl.isEmpty) {
expect(runtimeUserfrontUrl(), runtimeOriginFallback());
return;
}
expect(runtimeUserfrontUrl(), sanitizedUrl(_expectedUserfrontUrl));
},
);
test('dart-define URLs are sanitized', () {
if (_expectedBackendUrl.isEmpty || _expectedUserfrontUrl.isEmpty) {
return;
}
expect(runtimeBackendUrl(), isNot(endsWith('/')));
expect(runtimeUserfrontUrl(), isNot(endsWith('/')));
});
});
}