DEV Community

Cover image for I added a language switcher to my SaaS core, and the boring feature had two real bugs
Dmitry Isaenko
Dmitry Isaenko

Posted on

I added a language switcher to my SaaS core, and the boring feature had two real bugs

This is the next entry in a build-in-public series where I extract a real, production Laravel CRM into a reusable SaaS core, one module at a time, and ship each piece as a Composer package. So far: the foundation layer, auth on top of Fortify, multi-tenancy, roles and permissions, and the platform activity log. This one is v0.6.0: multilanguage.

And this is the post where the feature sounds the most boring and taught me the most. A language switcher. Everybody has one. You would not expect it to hide two bugs. It did, and both of them lived exactly where two systems meet.

Half of this already shipped, and that is the point

Here is the honest framing. The hard part of i18n was already in the box, since the very first foundation release.

Back in v0.1.0 the core already had the locale resolution chain: a single middleware that figures out the active language from the user's stored preference, then the session, then a cookie, then the Accept-Language header, then an optional geo lookup, then a default. Every one of those candidates is validated against a single allow-list before it is applied, so a junk code like ua or English can never reach the app or the database. There was a ValidLocale rule backing that same allow-list, and a HasLocalePreference contract so the core never assumes which column on your user model holds the locale.

What was missing was the visible half. There was no way for a user to actually pick a language, and the core shipped exactly one language: English. So v0.6.0 is not "i18n from scratch". It is the switch and the second language, bolted onto machinery that was already there and already tested. I am saying that out loud because it would be easy to write a headline that pretends this was a big new subsystem. It was not. It was the last 20 percent, and the last 20 percent is where the bugs were.

The two translation paths, because this confused me for an hour

Before the bugs, one thing worth explaining, because it is the kind of detail that is obvious only after you have gotten it wrong.

There are two completely separate ways a string gets translated in this stack, and they do not talk to each other.

The first is server-side. Mail subjects, flash messages, the geo fallbacks written into a log row. Those use Laravel's normal translation files under a larafoundry:: namespace, resolved with __('larafoundry::auth.verify_email.subject'). They never reach the browser.

The second is the frontend. The Vue pages call $t('Employees') with the English text itself as the key, and vue-i18n looks that up in a dictionary that the backend shares as an Inertia prop. That dictionary is built from the host's lang/{locale}.json file.

The trap: those larafoundry:: PHP files do not feed the frontend dictionary. So when I added Ukrainian, translating the PHP files got me Ukrainian email, but the Vue pages were still half English, because nothing was supplying their dictionary for the core's own screens. The fix was to ship a frontend dictionary inside the package and merge it underneath the host's, so the core's pages are translated out of the box and a host can still override any string:

// core's bundled frontend dictionary is the base layer,
// host's lang/{locale}.json spreads over it (host always wins),
// then host's php groups, matching Laravel's own loader.
$data = $this->coreFrontendStrings($locale);

if (File::exists($jsonPath)) {
    $json = json_decode((string) File::get($jsonPath), true);
    if (is_array($json)) {
        $data = [...$data, ...$json];
    }
}
Enter fullscreen mode Exit fullscreen mode

The decision that falls out of this: the core ships exactly two languages, English and Ukrainian. Not "any language". I needed Ukrainian for the first real project on top of this core, and English is the base. Translating the world's languages and keeping them in sync forever is not the core's job, it is the host's. Two out of the box, the door open for the rest.

Bug one: the language switch was an open redirect

The switch route is dead simple. Validate the submitted code against the allow-list, write it to the session and a cookie, write the database preference if the user is signed in, then send them back to where they were. That last step, "back to where they were", is where it went wrong.

The obvious way to write it is Laravel's back(). And back() resolves "where they were" from URL::previous(), which falls back to the Referer header when the session has no stored previous URL. The Referer header is set by the browser and trivially forged. So an attacker can hand a victim a link that posts a language switch with a Referer pointing at an external phishing page, and the switch dutifully redirects them off-site. That is a textbook open redirect, on a route whose entire job is to be harmless.

I caught this with a test before the review, which is the part I am quietly proud of, because the test is what made it real:

it('does not redirect to an external url even with a forged referer', function () {
    $response = $this->withHeaders(['referer' => 'https://evil.example/phish'])
        ->post(route('larafoundry.language.switch'), ['locale' => 'uk']);

    expect($response->headers->get('Location'))->not->toContain('evil.example');
});
Enter fullscreen mode Exit fullscreen mode

Red. The fix is to not trust the previous URL blindly: accept it only when it points at this application's own host, otherwise fall back to the app root.

protected function safeReturnUrl(Request $request): string
{
    $previous = URL::previous();
    $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
    $previousHost = parse_url($previous, PHP_URL_HOST);

    // relative URLs (no host) and same-host URLs are safe; anything else is not.
    if ($previousHost === null || $previousHost === $appHost) {
        return $previous;
    }

    return URL::to('/');
}
Enter fullscreen mode Exit fullscreen mode

The review then confirmed the guard holds for the cases I had not enumerated by hand: a scheme-relative //evil.example/x still parses a host and gets rejected, a bare relative path has no host and is allowed. The lesson here is not "open redirects exist". It is that the most boring, lowest-stakes route in the whole module was the one that shipped a real vulnerability, because the convenient helper (back()) does something slightly more trusting than you assume.

Bug two: a company name that silently never appeared

This one the review found, and I would not have. It is the cleanest example of a seam bug I have hit in this series.

The invitation page has a heading: "Join Acme Co". In the Vue component it was written like this:

:title="$t('Join :company', { company })"
Enter fullscreen mode Exit fullscreen mode

Read it quickly and it looks right. There is a placeholder, there is an argument. The problem is that :company is Laravel's placeholder syntax, from __(). But this is vue-i18n, and vue-i18n's placeholder syntax is {company} with curly braces. vue-i18n has no idea :company means anything, so it renders it verbatim and silently ignores the argument I passed. The user does not see "Join Acme Co". They see, literally, "Join :company".

Nothing errors. vue-i18n is configured to fall back silently on a missing key, which is correct behavior for an English-as-key dictionary, but it means a malformed placeholder is invisible. And this was the only interpolated $t() call in the entire frontend, so no other screen exercised the mismatch and exposed it. The fix is trivial once you see it, switch both the call and the dictionary entries to the vue-i18n syntax:

:title="$t('Join {company}', { company })"
Enter fullscreen mode Exit fullscreen mode

The reason I like this bug: it is not a logic error, it is a literacy error. The same idea ("substitute a value into a string") has two different spellings in two tools that sit two lines apart in the same feature, and using one tool's spelling in the other tool fails quietly instead of loudly. You only catch it by actually rendering the screen with a real company, or by a reviewer who knows both syntaxes and reads the brace.

What it deliberately is not

Same honesty note I put on every release. v0.6.0 ships a language switcher, the switch route, the second language, and the core's own screens translated. It does not ship machine translation. There is no DeepL or Google integration in here, and that is on purpose: machine translation is for translating user content, it pairs with database-backed translations of your models, and both of those are a different phase, probably a paid add-on, not this one.

It also does not magically translate a host's own pages. The core ships its own screens in English and Ukrainian; your application's strings are yours to translate, from your own lang/{locale}.json, which sits on top of the core dictionary and overrides it freely.

Tests, and the close

The package is green: the multilanguage suite covers the switch persisting to session, cookie and the database preference, the round-trip that proves the choice survives the next request instead of bouncing back, the rejection of junk locale codes through the allow-list, the open-redirect guard, the layered translation merge with a host override winning over the core, and the available-locales list the switcher reads. The frontend suite covers the switcher rendering, switching, and rendering nothing when there is only one language to choose.

The pattern from this series held again, and harder than usual this time, because the feature was so small: the bugs were not in the interesting code. They were in the convenient redirect helper and in a one-line placeholder that used the wrong dialect. A switcher. The most boring feature in the backlog, and it was the one that shipped an open redirect.

Next up is navigation and the rest of the operator console.

The package is public on GitHub: https://github.com/dmitryisaenko/larafoundry

Top comments (0)